├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── feedgenerator ├── __init__.py └── django │ ├── __init__.py │ └── utils │ ├── __init__.py │ ├── datetime_safe.py │ ├── encoding.py │ ├── feedgenerator.py │ ├── functional.py │ ├── timezone.py │ └── xmlutils.py ├── pyproject.toml ├── tests ├── __init__.py ├── test_feedgenerator.py ├── test_stringio.py └── usage_example.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | max_line_length = 88 13 | 14 | [*.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | pull_request: 7 | branches: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: Test - Python ${{ matrix.python-version }} 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Upgrade Pip 27 | run: python -m pip install -U pip 28 | - name: Install package and development dependencies 29 | run: python -m pip install -e .[dev] 30 | - name: Test with pytest 31 | run: | 32 | pytest 33 | 34 | deploy: 35 | name: Deploy 36 | environment: Deployment 37 | needs: test 38 | runs-on: ubuntu-latest 39 | if: github.ref=='refs/heads/main' && github.event_name!='pull_request' 40 | 41 | permissions: 42 | contents: write 43 | id-token: write 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - name: Set up Python 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: "3.10" 52 | 53 | - name: Check release 54 | id: check_release 55 | run: | 56 | python -m pip install autopub[github] 57 | autopub check 58 | 59 | - name: Publish 60 | if: ${{ steps.check_release.outputs.autopub_release=='true' }} 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | run: | 64 | autopub prepare 65 | autopub commit 66 | autopub build 67 | autopub githubrelease 68 | 69 | - name: Upload package to PyPI 70 | if: ${{ steps.check_release.outputs.autopub_release=='true' }} 71 | uses: pypa/gh-action-pypi-publish@release/v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | htmlcov/ 23 | 24 | #Translations 25 | *.mo 26 | 27 | #Mr Developer 28 | .mr.developer.cfg 29 | 30 | #VIM 31 | .*.swp 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.6.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-case-conflict 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: debug-statements 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: forbid-new-submodules 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: v3.15.2 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py38-plus] 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 2.1.0 - 2023-04-14 5 | ------------------ 6 | 7 | * Modernize and improve tests ([#32](https://github.com/getpelican/feedgenerator/pull/32) & [#34](https://github.com/getpelican/feedgenerator/pull/34) — thanks to @venthur) 8 | * Drop support for Python 3.6 and test on 3.10 & 3.11 ([#35](https://github.com/getpelican/feedgenerator/pull/35) — thanks to @hugovk) 9 | * Exclude `tests_feedgenerator/__pycache__` from distribution ([#33](https://github.com/getpelican/feedgenerator/pull/33) — thanks to @BenSturmfels) 10 | 11 | 2.0.0 - 2021-09-28 12 | ------------------ 13 | 14 | * Add preliminary support for adding images to feeds 15 | * Update code for Python 3.6+ 16 | * Drop support for Python 2.7 17 | * Fix double subtitles if both description & subtitle are provided 18 | 19 | 1.9.2 - 2021-08-18 20 | ------------------ 21 | 22 | Use description field as subtitle for Atom feeds, if provided 23 | 24 | 1.9.1 - 2020-02-09 25 | ------------------ 26 | 27 | Trim files included in source tarball 28 | 29 | 1.9.0 - 2016-09-12 30 | ------------------ 31 | 32 | * Always set the `updated` element of an `entry` in Atom feeds 33 | * Change `get_tag_uri()` so the `/` before a fragment gets only added if there is a fragment 34 | 35 | 1.8.0 - 2016-04-05 36 | ------------------ 37 | 38 | * Support Atom’s `` element 39 | * Put Atom pubdate in ``, not `` 40 | * Support giving an explicit `` for Atom 41 | 42 | 1.7.0 - 2013-08-31 43 | ------------------ 44 | 45 | Set minimum pytz version 46 | 47 | 1.6.0 - 2013-06-02 48 | ------------------ 49 | 50 | Add Python 3 support 51 | 52 | 1.5.0 - 2013-01-11 53 | ------------------ 54 | 55 | Added tests 56 | 57 | 1.2.1 - 2010-08-19 58 | ------------------ 59 | 60 | Initial packaged release 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Django Software Foundation and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE tox.ini 2 | recursive-include tests * 3 | exclude tests/__pycache__/* 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | FeedGenerator 2 | ============= 3 | 4 | |githubci| |pypi| 5 | 6 | .. |githubci| image:: https://img.shields.io/github/actions/workflow/status/getpelican/feedgenerator/main.yml?branch=main 7 | :target: https://github.com/getpelican/feedgenerator/actions 8 | :alt: Build Status 9 | 10 | .. |pypi| image:: https://img.shields.io/pypi/v/feedgenerator 11 | :target: https://pypi.org/project/feedgenerator/ 12 | :alt: PyPI Version 13 | 14 | FeedGenerator is a standalone version of Django’s feedgenerator_ module. 15 | It has evolved over time and includes numerous enhancements. 16 | 17 | .. _feedgenerator: https://github.com/django/django/blob/master/django/utils/feedgenerator.py 18 | -------------------------------------------------------------------------------- /feedgenerator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Syndication feed generation library -- used for generating RSS, etc. 3 | 4 | Sample usage: 5 | 6 | >>> import feedgenerator 7 | >>> feed = feedgenerator.Rss201rev2Feed( 8 | ... title="Poynter E-Media Tidbits", 9 | ... link="http://www.poynter.org/column.asp?id=31", 10 | ... description="A group Weblog by the sharpest minds in online media/journalism/publishing.", 11 | ... language="en", 12 | ... ) 13 | >>> feed.add_item( 14 | ... title="Hello", 15 | ... link="http://www.holovaty.com/test/", 16 | ... description="Testing." 17 | ... ) 18 | >>> with open('test.rss', 'w') as fp: 19 | ... feed.write(fp, 'utf-8') 20 | 21 | For definitions of the different versions of RSS, see: 22 | http://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004/02/04/incompatible-rss 23 | """ 24 | from .django.utils.feedgenerator import * 25 | -------------------------------------------------------------------------------- /feedgenerator/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getpelican/feedgenerator/c1e47f427d95ec71d5cfe651b268b063e5788741/feedgenerator/django/__init__.py -------------------------------------------------------------------------------- /feedgenerator/django/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getpelican/feedgenerator/c1e47f427d95ec71d5cfe651b268b063e5788741/feedgenerator/django/utils/__init__.py -------------------------------------------------------------------------------- /feedgenerator/django/utils/datetime_safe.py: -------------------------------------------------------------------------------- 1 | # Python's datetime strftime doesn't handle dates before 1900. 2 | # These classes override date and datetime to support the formatting of a date 3 | # through its full "proleptic Gregorian" date range. 4 | # 5 | # Based on code submitted to comp.lang.python by Andrew Dalke 6 | # 7 | # >>> datetime_safe.date(1850, 8, 2).strftime("%Y/%m/%d was a %A") 8 | # '1850/08/02 was a Friday' 9 | 10 | from datetime import date as real_date, datetime as real_datetime 11 | import re 12 | import time 13 | 14 | class date(real_date): 15 | def strftime(self, fmt): 16 | return strftime(self, fmt) 17 | 18 | class datetime(real_datetime): 19 | def strftime(self, fmt): 20 | return strftime(self, fmt) 21 | 22 | def combine(self, date, time): 23 | return datetime(date.year, date.month, date.day, time.hour, time.minute, time.microsecond, time.tzinfo) 24 | 25 | def date(self): 26 | return date(self.year, self.month, self.day) 27 | 28 | def new_date(d): 29 | "Generate a safe date from a datetime.date object." 30 | return date(d.year, d.month, d.day) 31 | 32 | def new_datetime(d): 33 | """ 34 | Generate a safe datetime from a datetime.date or datetime.datetime object. 35 | """ 36 | kw = [d.year, d.month, d.day] 37 | if isinstance(d, real_datetime): 38 | kw.extend([d.hour, d.minute, d.second, d.microsecond, d.tzinfo]) 39 | return datetime(*kw) 40 | 41 | # This library does not support strftime's "%s" or "%y" format strings. 42 | # Allowed if there's an even number of "%"s because they are escaped. 43 | _illegal_formatting = re.compile(r"((^|[^%])(%%)*%[sy])") 44 | 45 | def _findall(text, substr): 46 | # Also finds overlaps 47 | sites = [] 48 | i = 0 49 | while 1: 50 | j = text.find(substr, i) 51 | if j == -1: 52 | break 53 | sites.append(j) 54 | i=j+1 55 | return sites 56 | 57 | def strftime(dt, fmt): 58 | if dt.year >= 1900: 59 | return super(type(dt), dt).strftime(fmt) 60 | illegal_formatting = _illegal_formatting.search(fmt) 61 | if illegal_formatting: 62 | raise TypeError("strftime of dates before 1900 does not handle" + illegal_formatting.group(0)) 63 | 64 | year = dt.year 65 | # For every non-leap year century, advance by 66 | # 6 years to get into the 28-year repeat cycle 67 | delta = 2000 - year 68 | off = 6 * (delta // 100 + delta // 400) 69 | year = year + off 70 | 71 | # Move to around the year 2000 72 | year = year + ((2000 - year) // 28) * 28 73 | timetuple = dt.timetuple() 74 | s1 = time.strftime(fmt, (year,) + timetuple[1:]) 75 | sites1 = _findall(s1, str(year)) 76 | 77 | s2 = time.strftime(fmt, (year+28,) + timetuple[1:]) 78 | sites2 = _findall(s2, str(year+28)) 79 | 80 | sites = [] 81 | for site in sites1: 82 | if site in sites2: 83 | sites.append(site) 84 | 85 | s = s1 86 | syear = "%04d" % (dt.year,) 87 | for site in sites: 88 | s = s[:site] + syear + s[site+4:] 89 | return s 90 | -------------------------------------------------------------------------------- /feedgenerator/django/utils/encoding.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import datetime 3 | from decimal import Decimal 4 | import locale 5 | from urllib.parse import quote 6 | 7 | from .functional import Promise 8 | 9 | class DjangoUnicodeDecodeError(UnicodeDecodeError): 10 | def __init__(self, obj, *args): 11 | self.obj = obj 12 | UnicodeDecodeError.__init__(self, *args) 13 | 14 | def __str__(self): 15 | original = UnicodeDecodeError.__str__(self) 16 | return '{}. You passed in {!r} ({})'.format(original, self.obj, 17 | type(self.obj)) 18 | 19 | def smart_text(s, encoding='utf-8', strings_only=False, errors='strict'): 20 | """ 21 | Returns a text object representing 's' -- unicode on Python 2 and str on 22 | Python 3. Treats bytestrings using the 'encoding' codec. 23 | 24 | If strings_only is True, don't convert (some) non-string-like objects. 25 | """ 26 | if isinstance(s, Promise): 27 | # The input is the result of a gettext_lazy() call. 28 | return s 29 | return force_text(s, encoding, strings_only, errors) 30 | 31 | def is_protected_type(obj): 32 | """Determine if the object instance is of a protected type. 33 | 34 | Objects of protected types are preserved as-is when passed to 35 | force_text(strings_only=True). 36 | """ 37 | return isinstance(obj, (int, ) + (type(None), float, Decimal, 38 | datetime.datetime, datetime.date, datetime.time)) 39 | 40 | def force_text(s, encoding='utf-8', strings_only=False, errors='strict'): 41 | """ 42 | Similar to smart_text, except that lazy instances are resolved to 43 | strings, rather than kept as lazy objects. 44 | 45 | If strings_only is True, don't convert (some) non-string-like objects. 46 | """ 47 | # Handle the common case first, saves 30-40% when s is an instance 48 | # of str. This function gets called often in that setting. 49 | if isinstance(s, str): 50 | return s 51 | if strings_only and is_protected_type(s): 52 | return s 53 | try: 54 | if not isinstance(s, str): 55 | if hasattr(s, '__unicode__'): 56 | s = s.__unicode__() 57 | else: 58 | try: 59 | if isinstance(s, bytes): 60 | s = str(s, encoding, errors) 61 | else: 62 | s = str(s) 63 | except UnicodeEncodeError: 64 | if not isinstance(s, Exception): 65 | raise 66 | # If we get to here, the caller has passed in an Exception 67 | # subclass populated with non-ASCII data without special 68 | # handling to display as a string. We need to handle this 69 | # without raising a further exception. We do an 70 | # approximation to what the Exception's standard str() 71 | # output should be. 72 | s = ' '.join([force_text(arg, encoding, strings_only, 73 | errors) for arg in s]) 74 | else: 75 | # Note: We use .decode() here, instead of str(s, encoding, 76 | # errors), so that if s is a SafeBytes, it ends up being a 77 | # SafeText at the end. 78 | s = s.decode(encoding, errors) 79 | except UnicodeDecodeError as e: 80 | if not isinstance(s, Exception): 81 | raise DjangoUnicodeDecodeError(s, *e.args) 82 | else: 83 | # If we get to here, the caller has passed in an Exception 84 | # subclass populated with non-ASCII bytestring data without a 85 | # working unicode method. Try to handle this without raising a 86 | # further exception by individually forcing the exception args 87 | # to unicode. 88 | s = ' '.join([force_text(arg, encoding, strings_only, 89 | errors) for arg in s]) 90 | return s 91 | 92 | def smart_bytes(s, encoding='utf-8', strings_only=False, errors='strict'): 93 | """ 94 | Returns a bytestring version of 's', encoded as specified in 'encoding'. 95 | 96 | If strings_only is True, don't convert (some) non-string-like objects. 97 | """ 98 | if isinstance(s, Promise): 99 | # The input is the result of a gettext_lazy() call. 100 | return s 101 | return force_bytes(s, encoding, strings_only, errors) 102 | 103 | 104 | def force_bytes(s, encoding='utf-8', strings_only=False, errors='strict'): 105 | """ 106 | Similar to smart_bytes, except that lazy instances are resolved to 107 | strings, rather than kept as lazy objects. 108 | 109 | If strings_only is True, don't convert (some) non-string-like objects. 110 | """ 111 | if isinstance(s, bytes): 112 | if encoding == 'utf-8': 113 | return s 114 | else: 115 | return s.decode('utf-8', errors).encode(encoding, errors) 116 | if strings_only and (s is None or isinstance(s, int)): 117 | return s 118 | if isinstance(s, Promise): 119 | return str.encode(encoding, errors) 120 | if not isinstance(s, str): 121 | try: 122 | return str(s).encode(encoding) 123 | except UnicodeEncodeError: 124 | if isinstance(s, Exception): 125 | # An Exception subclass containing non-ASCII data that doesn't 126 | # know how to print itself properly. We shouldn't raise a 127 | # further exception. 128 | return ' '.join([smart_bytes(arg, encoding, strings_only, 129 | errors) for arg in s]) 130 | return str(s).encode(encoding, errors) 131 | else: 132 | return s.encode(encoding, errors) 133 | 134 | 135 | smart_str = smart_text 136 | force_str = force_text 137 | 138 | smart_str.__doc__ = """\ 139 | Apply smart_text in Python 3 and smart_bytes in Python 2. 140 | 141 | This is suitable for writing to sys.stdout (for instance). 142 | """ 143 | 144 | force_str.__doc__ = """\ 145 | Apply force_text in Python 3 and force_bytes in Python 2. 146 | """ 147 | 148 | def iri_to_uri(iri): 149 | """ 150 | Convert an Internationalized Resource Identifier (IRI) portion to a URI 151 | portion that is suitable for inclusion in a URL. 152 | 153 | This is the algorithm from section 3.1 of RFC 3987. However, since we are 154 | assuming input is either UTF-8 or unicode already, we can simplify things a 155 | little from the full method. 156 | 157 | Returns an ASCII string containing the encoded result. 158 | """ 159 | # The list of safe characters here is constructed from the "reserved" and 160 | # "unreserved" characters specified in sections 2.2 and 2.3 of RFC 3986: 161 | # reserved = gen-delims / sub-delims 162 | # gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" 163 | # sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 164 | # / "*" / "+" / "," / ";" / "=" 165 | # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 166 | # Of the unreserved characters, urllib.quote already considers all but 167 | # the ~ safe. 168 | # The % character is also added to the list of safe characters here, as the 169 | # end of section 3.1 of RFC 3987 specifically mentions that % must not be 170 | # converted. 171 | if iri is None: 172 | return iri 173 | return quote(smart_bytes(iri), safe=b"/#%[]=:;$&()+,!?*@'~") 174 | 175 | def filepath_to_uri(path): 176 | """Convert an file system path to a URI portion that is suitable for 177 | inclusion in a URL. 178 | 179 | We are assuming input is either UTF-8 or unicode already. 180 | 181 | This method will encode certain chars that would normally be recognized as 182 | special chars for URIs. Note that this method does not encode the ' 183 | character, as it is a valid character within URIs. See 184 | encodeURIComponent() JavaScript function for more details. 185 | 186 | Returns an ASCII string containing the encoded result. 187 | """ 188 | if path is None: 189 | return path 190 | # I know about `os.sep` and `os.altsep` but I want to leave 191 | # some flexibility for hardcoding separators. 192 | return quote(smart_bytes(path.replace("\\", "/")), safe=b"/~!*()'") 193 | 194 | # The encoding of the default system locale but falls back to the 195 | # given fallback encoding if the encoding is unsupported by python or could 196 | # not be determined. See tickets #10335 and #5846 197 | try: 198 | DEFAULT_LOCALE_ENCODING = locale.getlocale()[1] or 'ascii' 199 | codecs.lookup(DEFAULT_LOCALE_ENCODING) 200 | except: 201 | DEFAULT_LOCALE_ENCODING = 'ascii' 202 | -------------------------------------------------------------------------------- /feedgenerator/django/utils/feedgenerator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Syndication feed generation library -- used for generating RSS, etc. 3 | 4 | Sample usage: 5 | 6 | >>> from django.utils import feedgenerator 7 | >>> feed = feedgenerator.Rss201rev2Feed( 8 | ... title="Poynter E-Media Tidbits", 9 | ... link="http://www.poynter.org/column.asp?id=31", 10 | ... description="A group Weblog by the sharpest minds in online media/journalism/publishing.", 11 | ... language="en", 12 | ... ) 13 | >>> feed.add_item( 14 | ... title="Hello", 15 | ... link="http://www.holovaty.com/test/", 16 | ... description="Testing." 17 | ... ) 18 | >>> with open('test.rss', 'w') as fp: 19 | ... feed.write(fp, 'utf-8') 20 | 21 | For definitions of the different versions of RSS, see: 22 | http://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004/02/04/incompatible-rss 23 | """ 24 | import datetime 25 | from urllib.parse import urlparse 26 | from .xmlutils import SimplerXMLGenerator 27 | from .encoding import force_text, iri_to_uri 28 | from . import datetime_safe 29 | from io import StringIO 30 | from .timezone import is_aware 31 | 32 | def rfc2822_date(date): 33 | # We can't use strftime() because it produces locale-dependent results, so 34 | # we have to map english month and day names manually 35 | months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',) 36 | days = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun') 37 | # Support datetime objects older than 1900 38 | date = datetime_safe.new_datetime(date) 39 | # We do this ourselves to be timezone aware, email.Utils is not tz aware. 40 | dow = days[date.weekday()] 41 | month = months[date.month - 1] 42 | time_str = date.strftime(f'{dow}, %d {month} %Y %H:%M:%S ') 43 | if is_aware(date): 44 | offset = date.tzinfo.utcoffset(date) 45 | timezone = (offset.days * 24 * 60) + (offset.seconds // 60) 46 | hour, minute = divmod(timezone, 60) 47 | return time_str + '%+03d%02d' % (hour, minute) 48 | else: 49 | return time_str + '-0000' 50 | 51 | def rfc3339_date(date): 52 | # Support datetime objects older than 1900 53 | date = datetime_safe.new_datetime(date) 54 | time_str = date.strftime('%Y-%m-%dT%H:%M:%S') 55 | if is_aware(date): 56 | offset = date.tzinfo.utcoffset(date) 57 | timezone = (offset.days * 24 * 60) + (offset.seconds // 60) 58 | hour, minute = divmod(timezone, 60) 59 | return time_str + '%+03d:%02d' % (hour, minute) 60 | else: 61 | return time_str + 'Z' 62 | 63 | def get_tag_uri(url, date): 64 | """ 65 | Creates a TagURI. 66 | 67 | See http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id 68 | """ 69 | bits = urlparse(url) 70 | d = '' 71 | if date is not None: 72 | d = ',%s' % datetime_safe.new_datetime(date).strftime('%Y-%m-%d') 73 | fragment = '' 74 | if bits.fragment != '': 75 | fragment = '/%s' % (bits.fragment) 76 | return f'tag:{bits.hostname}{d}:{bits.path}{fragment}' 77 | 78 | class SyndicationFeed: 79 | "Base class for all syndication feeds. Subclasses should provide write()" 80 | def __init__(self, title, link, description, language=None, author_email=None, 81 | author_name=None, author_link=None, subtitle=None, categories=None, 82 | feed_url=None, feed_copyright=None, image=None, feed_guid=None, ttl=None, **kwargs): 83 | to_unicode = lambda s: force_text(s, strings_only=True) 84 | if categories: 85 | categories = [force_text(c) for c in categories] 86 | if ttl is not None: 87 | # Force ints to unicode 88 | ttl = force_text(ttl) 89 | self.feed = { 90 | 'title': to_unicode(title), 91 | 'link': iri_to_uri(link), 92 | 'description': to_unicode(description), 93 | 'language': to_unicode(language), 94 | 'author_email': to_unicode(author_email), 95 | 'author_name': to_unicode(author_name), 96 | 'author_link': iri_to_uri(author_link), 97 | 'subtitle': to_unicode(subtitle), 98 | 'categories': categories or (), 99 | 'feed_url': iri_to_uri(feed_url), 100 | 'feed_copyright': to_unicode(feed_copyright), 101 | 'image': iri_to_uri(image), 102 | 'id': feed_guid or link, 103 | 'ttl': ttl, 104 | } 105 | self.feed.update(kwargs) 106 | self.items = [] 107 | 108 | def add_item(self, title, link, description, author_email=None, 109 | author_name=None, author_link=None, pubdate=None, comments=None, 110 | unique_id=None, enclosure=None, categories=(), item_copyright=None, 111 | ttl=None, content=None, updateddate=None, **kwargs): 112 | """ 113 | Adds an item to the feed. All args are expected to be Python Unicode 114 | objects except pubdate and updateddate, which are datetime.datetime 115 | objects, and enclosure, which is an instance of the Enclosure class. 116 | """ 117 | to_unicode = lambda s: force_text(s, strings_only=True) 118 | if categories: 119 | categories = [to_unicode(c) for c in categories] 120 | if ttl is not None: 121 | # Force ints to unicode 122 | ttl = force_text(ttl) 123 | item = { 124 | 'title': to_unicode(title), 125 | 'link': iri_to_uri(link), 126 | 'description': to_unicode(description), 127 | 'content': to_unicode(content), 128 | 'author_email': to_unicode(author_email), 129 | 'author_name': to_unicode(author_name), 130 | 'author_link': iri_to_uri(author_link), 131 | 'pubdate': pubdate, 132 | 'updateddate': updateddate, 133 | 'comments': to_unicode(comments), 134 | 'unique_id': to_unicode(unique_id), 135 | 'enclosure': enclosure, 136 | 'categories': categories or (), 137 | 'item_copyright': to_unicode(item_copyright), 138 | 'ttl': ttl, 139 | } 140 | item.update(kwargs) 141 | self.items.append(item) 142 | 143 | def num_items(self): 144 | return len(self.items) 145 | 146 | def root_attributes(self): 147 | """ 148 | Return extra attributes to place on the root (i.e. feed/channel) element. 149 | Called from write(). 150 | """ 151 | return {} 152 | 153 | def add_root_elements(self, handler): 154 | """ 155 | Add elements in the root (i.e. feed/channel) element. Called 156 | from write(). 157 | """ 158 | pass 159 | 160 | def item_attributes(self, item): 161 | """ 162 | Return extra attributes to place on each item (i.e. item/entry) element. 163 | """ 164 | return {} 165 | 166 | def add_item_elements(self, handler, item): 167 | """ 168 | Add elements on each item (i.e. item/entry) element. 169 | """ 170 | pass 171 | 172 | def write(self, outfile, encoding): 173 | """ 174 | Outputs the feed in the given encoding to outfile, which is a file-like 175 | object. Subclasses should override this. 176 | """ 177 | raise NotImplementedError 178 | 179 | def writeString(self, encoding): 180 | """ 181 | Returns the feed in the given encoding as a string. 182 | """ 183 | s = StringIO() 184 | self.write(s, encoding) 185 | return s.getvalue() 186 | 187 | def latest_post_date(self): 188 | """ 189 | Returns the latest item's pubdate or updateddate. If no item has either 190 | date, returns the current date/time. 191 | """ 192 | updates = [i['pubdate'] for i in self.items if i['pubdate'] is not None] 193 | updates.extend(i['updateddate'] for i in self.items if i['updateddate'] is not None) 194 | if len(updates) > 0: 195 | updates.sort() 196 | return updates[-1] 197 | else: 198 | return datetime.datetime.now() 199 | 200 | class Enclosure: 201 | "Represents an RSS enclosure" 202 | def __init__(self, url, length, mime_type): 203 | "All args are expected to be Python Unicode objects" 204 | self.length, self.mime_type = length, mime_type 205 | self.url = iri_to_uri(url) 206 | 207 | class RssFeed(SyndicationFeed): 208 | mime_type = 'application/rss+xml; charset=utf-8' 209 | def write(self, outfile, encoding): 210 | handler = SimplerXMLGenerator(outfile, encoding) 211 | handler.startDocument() 212 | handler.startElement("rss", self.rss_attributes()) 213 | handler.startElement("channel", self.root_attributes()) 214 | self.add_root_elements(handler) 215 | self.write_items(handler) 216 | self.endChannelElement(handler) 217 | handler.endElement("rss") 218 | 219 | def rss_attributes(self): 220 | return {'version': self._version} 221 | 222 | def write_items(self, handler): 223 | for item in self.items: 224 | handler.startElement('item', self.item_attributes(item)) 225 | self.add_item_elements(handler, item) 226 | handler.endElement("item") 227 | 228 | def add_root_elements(self, handler): 229 | # Required Elements as per the specification 230 | handler.addQuickElement("title", self.feed['title']) 231 | handler.addQuickElement("link", self.feed['link']) 232 | if self.feed['image'] is not None: 233 | handler.startElement('image', {}) 234 | handler.addQuickElement("url", self.feed['link']+self.feed['image']) 235 | handler.addQuickElement("title", self.feed['title']) 236 | handler.addQuickElement("link", self.feed['link']) 237 | handler.endElement('image') 238 | handler.addQuickElement("description", self.feed['description']) 239 | 240 | # Optional Channel Elements 241 | if self.feed['language'] is not None: 242 | handler.addQuickElement("language", self.feed['language']) 243 | for cat in self.feed['categories']: 244 | handler.addQuickElement("category", cat) 245 | if self.feed['feed_copyright'] is not None: 246 | handler.addQuickElement("copyright", self.feed['feed_copyright']) 247 | handler.addQuickElement("lastBuildDate", rfc2822_date(self.latest_post_date())) 248 | if self.feed['ttl'] is not None: 249 | handler.addQuickElement("ttl", self.feed['ttl']) 250 | 251 | def endChannelElement(self, handler): 252 | handler.endElement("channel") 253 | 254 | class RssUserland091Feed(RssFeed): 255 | _version = "0.91" 256 | def add_item_elements(self, handler, item): 257 | handler.addQuickElement("title", item['title']) 258 | handler.addQuickElement("link", item['link']) 259 | if item['description'] is not None: 260 | handler.addQuickElement("description", item['description']) 261 | 262 | class Rss201rev2Feed(RssFeed): 263 | # Spec: http://blogs.law.harvard.edu/tech/rss 264 | _version = "2.0" 265 | def add_item_elements(self, handler, item): 266 | handler.addQuickElement("title", item['title']) 267 | handler.addQuickElement("link", item['link']) 268 | if item['description'] is not None: 269 | handler.addQuickElement("description", item['description']) 270 | 271 | # Author information. 272 | if item["author_name"] and item["author_email"]: 273 | handler.addQuickElement("author", "%s (%s)" % \ 274 | (item['author_email'], item['author_name'])) 275 | elif item["author_email"]: 276 | handler.addQuickElement("author", item["author_email"]) 277 | elif item["author_name"]: 278 | handler.addQuickElement("dc:creator", item["author_name"], {"xmlns:dc": "http://purl.org/dc/elements/1.1/"}) 279 | 280 | if item['pubdate'] is not None: 281 | handler.addQuickElement("pubDate", rfc2822_date(item['pubdate'])) 282 | if item['comments'] is not None: 283 | handler.addQuickElement("comments", item['comments']) 284 | if item['unique_id'] is not None: 285 | handler.addQuickElement("guid", item['unique_id'], attrs={'isPermaLink': 'false'}) 286 | if item['ttl'] is not None: 287 | handler.addQuickElement("ttl", item['ttl']) 288 | 289 | # Enclosure. 290 | if item['enclosure'] is not None: 291 | handler.addQuickElement("enclosure", '', 292 | {"url": item['enclosure'].url, "length": item['enclosure'].length, 293 | "type": item['enclosure'].mime_type}) 294 | 295 | # Categories. 296 | for cat in item['categories']: 297 | handler.addQuickElement("category", cat) 298 | 299 | class Atom1Feed(SyndicationFeed): 300 | # Spec: http://atompub.org/2005/07/11/draft-ietf-atompub-format-10.html 301 | mime_type = 'application/atom+xml; charset=utf-8' 302 | ns = "http://www.w3.org/2005/Atom" 303 | 304 | def write(self, outfile, encoding): 305 | handler = SimplerXMLGenerator(outfile, encoding) 306 | handler.startDocument() 307 | handler.startElement('feed', self.root_attributes()) 308 | self.add_root_elements(handler) 309 | self.write_items(handler) 310 | handler.endElement("feed") 311 | 312 | def root_attributes(self): 313 | if self.feed['language'] is not None: 314 | return {"xmlns": self.ns, "xml:lang": self.feed['language']} 315 | else: 316 | return {"xmlns": self.ns} 317 | 318 | def add_root_elements(self, handler): 319 | handler.addQuickElement("title", self.feed['title']) 320 | handler.addQuickElement("link", "", {"rel": "alternate", "href": self.feed['link']}) 321 | if self.feed['feed_url'] is not None: 322 | handler.addQuickElement("link", "", {"rel": "self", "href": self.feed['feed_url']}) 323 | handler.addQuickElement("id", self.feed['id']) 324 | handler.addQuickElement("updated", rfc3339_date(self.latest_post_date())) 325 | if self.feed['author_name'] is not None: 326 | handler.startElement("author", {}) 327 | handler.addQuickElement("name", self.feed['author_name']) 328 | if self.feed['author_email'] is not None: 329 | handler.addQuickElement("email", self.feed['author_email']) 330 | if self.feed['author_link'] is not None: 331 | handler.addQuickElement("uri", self.feed['author_link']) 332 | handler.endElement("author") 333 | # try to use description or subtitle if provided, subtitle has 334 | # precedence above description 335 | if self.feed['subtitle']: 336 | handler.addQuickElement("subtitle", self.feed['subtitle']) 337 | elif self.feed['description']: 338 | handler.addQuickElement("subtitle", self.feed['description']) 339 | for cat in self.feed['categories']: 340 | handler.addQuickElement("category", "", {"term": cat}) 341 | if self.feed['feed_copyright'] is not None: 342 | handler.addQuickElement("rights", self.feed['feed_copyright']) 343 | 344 | def write_items(self, handler): 345 | for item in self.items: 346 | handler.startElement("entry", self.item_attributes(item)) 347 | self.add_item_elements(handler, item) 348 | handler.endElement("entry") 349 | 350 | def add_item_elements(self, handler, item): 351 | handler.addQuickElement("title", item['title']) 352 | handler.addQuickElement("link", "", {"href": item['link'], "rel": "alternate"}) 353 | 354 | updateddate = datetime.datetime.now() 355 | if item['pubdate'] is not None: 356 | handler.addQuickElement("published", rfc3339_date(item['pubdate'])) 357 | updateddate = item['pubdate'] 358 | if item['updateddate'] is not None: 359 | updateddate = item['updateddate'] 360 | handler.addQuickElement("updated", rfc3339_date(updateddate)) 361 | 362 | # Author information. 363 | if item['author_name'] is not None: 364 | handler.startElement("author", {}) 365 | handler.addQuickElement("name", item['author_name']) 366 | if item['author_email'] is not None: 367 | handler.addQuickElement("email", item['author_email']) 368 | if item['author_link'] is not None: 369 | handler.addQuickElement("uri", item['author_link']) 370 | handler.endElement("author") 371 | 372 | # Unique ID. 373 | if item['unique_id'] is not None: 374 | unique_id = item['unique_id'] 375 | else: 376 | unique_id = get_tag_uri(item['link'], item['pubdate']) 377 | handler.addQuickElement("id", unique_id) 378 | 379 | # Summary. 380 | if item['description'] is not None: 381 | handler.addQuickElement("summary", item['description'], {"type": "html"}) 382 | 383 | # Full content. 384 | if item['content'] is not None: 385 | handler.addQuickElement("content", item['content'], {"type": "html"}) 386 | 387 | # Enclosure. 388 | if item['enclosure'] is not None: 389 | handler.addQuickElement("link", '', 390 | {"rel": "enclosure", 391 | "href": item['enclosure'].url, 392 | "length": item['enclosure'].length, 393 | "type": item['enclosure'].mime_type}) 394 | 395 | # Categories. 396 | for cat in item['categories']: 397 | handler.addQuickElement("category", "", {"term": cat}) 398 | 399 | # Rights. 400 | if item['item_copyright'] is not None: 401 | handler.addQuickElement("rights", item['item_copyright']) 402 | 403 | # This isolates the decision of what the system default is, so calling code can 404 | # do "feedgenerator.DefaultFeed" instead of "feedgenerator.Rss201rev2Feed". 405 | DefaultFeed = Rss201rev2Feed 406 | -------------------------------------------------------------------------------- /feedgenerator/django/utils/functional.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import operator 3 | from functools import wraps, update_wrapper 4 | import sys 5 | 6 | # You can't trivially replace this `functools.partial` because this binds to 7 | # classes and returns bound instances, whereas functools.partial (on CPython) 8 | # is a type and its instances don't bind. 9 | def curry(_curried_func, *args, **kwargs): 10 | def _curried(*moreargs, **morekwargs): 11 | return _curried_func(*(args+moreargs), **dict(kwargs, **morekwargs)) 12 | return _curried 13 | 14 | def memoize(func, cache, num_args): 15 | """ 16 | Wrap a function so that results for any argument tuple are stored in 17 | 'cache'. Note that the args to the function must be usable as dictionary 18 | keys. 19 | 20 | Only the first num_args are considered when creating the key. 21 | """ 22 | @wraps(func) 23 | def wrapper(*args): 24 | mem_args = args[:num_args] 25 | if mem_args in cache: 26 | return cache[mem_args] 27 | result = func(*args) 28 | cache[mem_args] = result 29 | return result 30 | return wrapper 31 | 32 | class cached_property: 33 | """ 34 | Decorator that creates converts a method with a single 35 | self argument into a property cached on the instance. 36 | """ 37 | def __init__(self, func): 38 | self.func = func 39 | 40 | def __get__(self, instance, type): 41 | res = instance.__dict__[self.func.__name__] = self.func(instance) 42 | return res 43 | 44 | class Promise: 45 | """ 46 | This is just a base class for the proxy class created in 47 | the closure of the lazy function. It can be used to recognize 48 | promises in code. 49 | """ 50 | pass 51 | 52 | def lazy(func, *resultclasses): 53 | """ 54 | Turns any callable into a lazy evaluated callable. You need to give result 55 | classes or types -- at least one is needed so that the automatic forcing of 56 | the lazy evaluation code is triggered. Results are not memoized; the 57 | function is evaluated on every access. 58 | """ 59 | 60 | @total_ordering 61 | class __proxy__(Promise): 62 | """ 63 | Encapsulate a function call and act as a proxy for methods that are 64 | called on the result of that function. The function is not evaluated 65 | until one of the methods on the result is called. 66 | """ 67 | __dispatch = None 68 | 69 | def __init__(self, args, kw): 70 | self.__args = args 71 | self.__kw = kw 72 | if self.__dispatch is None: 73 | self.__prepare_class__() 74 | 75 | def __reduce__(self): 76 | return ( 77 | _lazy_proxy_unpickle, 78 | (func, self.__args, self.__kw) + resultclasses 79 | ) 80 | 81 | def __prepare_class__(cls): 82 | cls.__dispatch = {} 83 | for resultclass in resultclasses: 84 | cls.__dispatch[resultclass] = {} 85 | for type_ in reversed(resultclass.mro()): 86 | for (k, v) in type_.__dict__.items(): 87 | # All __promise__ return the same wrapper method, but they 88 | # also do setup, inserting the method into the dispatch 89 | # dict. 90 | meth = cls.__promise__(resultclass, k, v) 91 | if hasattr(cls, k): 92 | continue 93 | setattr(cls, k, meth) 94 | cls._delegate_bytes = bytes in resultclasses 95 | cls._delegate_text = str in resultclasses 96 | assert not (cls._delegate_bytes and cls._delegate_text), "Cannot call lazy() with both bytes and text return types." 97 | if cls._delegate_text: 98 | cls.__str__ = cls.__text_cast 99 | elif cls._delegate_bytes: 100 | cls.__bytes__ = cls.__bytes_cast 101 | __prepare_class__ = classmethod(__prepare_class__) 102 | 103 | def __promise__(cls, klass, funcname, method): 104 | # Builds a wrapper around some magic method and registers that magic 105 | # method for the given type and method name. 106 | def __wrapper__(self, *args, **kw): 107 | # Automatically triggers the evaluation of a lazy value and 108 | # applies the given magic method of the result type. 109 | res = func(*self.__args, **self.__kw) 110 | for t in type(res).mro(): 111 | if t in self.__dispatch: 112 | return self.__dispatch[t][funcname](res, *args, **kw) 113 | raise TypeError("Lazy object returned unexpected type.") 114 | 115 | if klass not in cls.__dispatch: 116 | cls.__dispatch[klass] = {} 117 | cls.__dispatch[klass][funcname] = method 118 | return __wrapper__ 119 | __promise__ = classmethod(__promise__) 120 | 121 | def __text_cast(self): 122 | return func(*self.__args, **self.__kw) 123 | 124 | def __bytes_cast(self): 125 | return bytes(func(*self.__args, **self.__kw)) 126 | 127 | def __cast(self): 128 | if self._delegate_bytes: 129 | return self.__bytes_cast() 130 | elif self._delegate_text: 131 | return self.__text_cast() 132 | else: 133 | return func(*self.__args, **self.__kw) 134 | 135 | def __eq__(self, other): 136 | if isinstance(other, Promise): 137 | other = other.__cast() 138 | return self.__cast() == other 139 | 140 | def __lt__(self, other): 141 | if isinstance(other, Promise): 142 | other = other.__cast() 143 | return self.__cast() < other 144 | 145 | __hash__ = object.__hash__ 146 | 147 | def __mod__(self, rhs): 148 | if self._delegate_text: 149 | return str(self) % rhs 150 | else: 151 | raise AssertionError('__mod__ not supported for non-string types') 152 | 153 | def __deepcopy__(self, memo): 154 | # Instances of this class are effectively immutable. It's just a 155 | # collection of functions. So we don't need to do anything 156 | # complicated for copying. 157 | memo[id(self)] = self 158 | return self 159 | 160 | @wraps(func) 161 | def __wrapper__(*args, **kw): 162 | # Creates the proxy object, instead of the actual value. 163 | return __proxy__(args, kw) 164 | 165 | return __wrapper__ 166 | 167 | def _lazy_proxy_unpickle(func, args, kwargs, *resultclasses): 168 | return lazy(func, *resultclasses)(*args, **kwargs) 169 | 170 | def allow_lazy(func, *resultclasses): 171 | """ 172 | A decorator that allows a function to be called with one or more lazy 173 | arguments. If none of the args are lazy, the function is evaluated 174 | immediately, otherwise a __proxy__ is returned that will evaluate the 175 | function when needed. 176 | """ 177 | @wraps(func) 178 | def wrapper(*args, **kwargs): 179 | for arg in list(args) + list(kwargs.values()): 180 | if isinstance(arg, Promise): 181 | break 182 | else: 183 | return func(*args, **kwargs) 184 | return lazy(func, *resultclasses)(*args, **kwargs) 185 | return wrapper 186 | 187 | empty = object() 188 | def new_method_proxy(func): 189 | def inner(self, *args): 190 | if self._wrapped is empty: 191 | self._setup() 192 | return func(self._wrapped, *args) 193 | return inner 194 | 195 | class LazyObject: 196 | """ 197 | A wrapper for another class that can be used to delay instantiation of the 198 | wrapped class. 199 | 200 | By subclassing, you have the opportunity to intercept and alter the 201 | instantiation. If you don't need to do that, use SimpleLazyObject. 202 | """ 203 | def __init__(self): 204 | self._wrapped = empty 205 | 206 | __getattr__ = new_method_proxy(getattr) 207 | 208 | def __setattr__(self, name, value): 209 | if name == "_wrapped": 210 | # Assign to __dict__ to avoid infinite __setattr__ loops. 211 | self.__dict__["_wrapped"] = value 212 | else: 213 | if self._wrapped is empty: 214 | self._setup() 215 | setattr(self._wrapped, name, value) 216 | 217 | def __delattr__(self, name): 218 | if name == "_wrapped": 219 | raise TypeError("can't delete _wrapped.") 220 | if self._wrapped is empty: 221 | self._setup() 222 | delattr(self._wrapped, name) 223 | 224 | def _setup(self): 225 | """ 226 | Must be implemented by subclasses to initialise the wrapped object. 227 | """ 228 | raise NotImplementedError 229 | 230 | # introspection support: 231 | __dir__ = new_method_proxy(dir) 232 | 233 | 234 | # Workaround for http://bugs.python.org/issue12370 235 | _super = super 236 | 237 | class SimpleLazyObject(LazyObject): 238 | """ 239 | A lazy object initialised from any function. 240 | 241 | Designed for compound objects of unknown type. For builtins or objects of 242 | known type, use django.utils.functional.lazy. 243 | """ 244 | def __init__(self, func): 245 | """ 246 | Pass in a callable that returns the object to be wrapped. 247 | 248 | If copies are made of the resulting SimpleLazyObject, which can happen 249 | in various circumstances within Django, then you must ensure that the 250 | callable can be safely run more than once and will return the same 251 | value. 252 | """ 253 | self.__dict__['_setupfunc'] = func 254 | _super(SimpleLazyObject, self).__init__() 255 | 256 | def _setup(self): 257 | self._wrapped = self._setupfunc() 258 | 259 | __bytes__ = new_method_proxy(bytes) 260 | __str__ = new_method_proxy(str) 261 | 262 | def __deepcopy__(self, memo): 263 | if self._wrapped is empty: 264 | # We have to use SimpleLazyObject, not self.__class__, because the 265 | # latter is proxied. 266 | result = SimpleLazyObject(self._setupfunc) 267 | memo[id(self)] = result 268 | return result 269 | else: 270 | return copy.deepcopy(self._wrapped, memo) 271 | 272 | # Because we have messed with __class__ below, we confuse pickle as to what 273 | # class we are pickling. It also appears to stop __reduce__ from being 274 | # called. So, we define __getstate__ in a way that cooperates with the way 275 | # that pickle interprets this class. This fails when the wrapped class is a 276 | # builtin, but it is better than nothing. 277 | def __getstate__(self): 278 | if self._wrapped is empty: 279 | self._setup() 280 | return self._wrapped.__dict__ 281 | 282 | # Need to pretend to be the wrapped class, for the sake of objects that care 283 | # about this (especially in equality tests) 284 | __class__ = property(new_method_proxy(operator.attrgetter("__class__"))) 285 | __eq__ = new_method_proxy(operator.eq) 286 | __hash__ = new_method_proxy(hash) 287 | __bool__ = new_method_proxy(bool) # Python 3 288 | __nonzero__ = __bool__ # Python 2 289 | 290 | 291 | class lazy_property(property): 292 | """ 293 | A property that works with subclasses by wrapping the decorated 294 | functions of the base class. 295 | """ 296 | def __new__(cls, fget=None, fset=None, fdel=None, doc=None): 297 | if fget is not None: 298 | @wraps(fget) 299 | def fget(instance, instance_type=None, name=fget.__name__): 300 | return getattr(instance, name)() 301 | if fset is not None: 302 | @wraps(fset) 303 | def fset(instance, value, name=fset.__name__): 304 | return getattr(instance, name)(value) 305 | if fdel is not None: 306 | @wraps(fdel) 307 | def fdel(instance, name=fdel.__name__): 308 | return getattr(instance, name)() 309 | return property(fget, fset, fdel, doc) 310 | 311 | def partition(predicate, values): 312 | """ 313 | Splits the values into two sets, based on the return value of the function 314 | (True/False). e.g.: 315 | 316 | >>> partition(lambda x: x > 3, range(5)) 317 | [0, 1, 2, 3], [4] 318 | """ 319 | results = ([], []) 320 | for item in values: 321 | results[predicate(item)].append(item) 322 | return results 323 | 324 | from functools import total_ordering 325 | -------------------------------------------------------------------------------- /feedgenerator/django/utils/timezone.py: -------------------------------------------------------------------------------- 1 | """Timezone helper functions. 2 | 3 | This module uses pytz when it's available and fallbacks when it isn't. 4 | """ 5 | 6 | from datetime import datetime, timedelta, tzinfo 7 | from threading import local 8 | import time as _time 9 | 10 | try: 11 | import pytz 12 | except ImportError: 13 | pytz = None 14 | 15 | # ### from django.conf import settings 16 | __all__ = [ 17 | 'utc', 'get_default_timezone', 'get_current_timezone', 18 | 'activate', 'deactivate', 'override', 19 | 'is_naive', 'is_aware', 'make_aware', 'make_naive', 20 | ] 21 | 22 | 23 | # UTC and local time zones 24 | 25 | ZERO = timedelta(0) 26 | 27 | class UTC(tzinfo): 28 | """ 29 | UTC implementation taken from Python's docs. 30 | 31 | Used only when pytz isn't available. 32 | """ 33 | 34 | def __repr__(self): 35 | return "" 36 | 37 | def utcoffset(self, dt): 38 | return ZERO 39 | 40 | def tzname(self, dt): 41 | return "UTC" 42 | 43 | def dst(self, dt): 44 | return ZERO 45 | 46 | class LocalTimezone(tzinfo): 47 | """ 48 | Local time implementation taken from Python's docs. 49 | 50 | Used only when pytz isn't available, and most likely inaccurate. If you're 51 | having trouble with this class, don't waste your time, just install pytz. 52 | """ 53 | 54 | def __init__(self): 55 | # This code is moved in __init__ to execute it as late as possible 56 | # See get_default_timezone(). 57 | self.STDOFFSET = timedelta(seconds=-_time.timezone) 58 | if _time.daylight: 59 | self.DSTOFFSET = timedelta(seconds=-_time.altzone) 60 | else: 61 | self.DSTOFFSET = self.STDOFFSET 62 | self.DSTDIFF = self.DSTOFFSET - self.STDOFFSET 63 | tzinfo.__init__(self) 64 | 65 | def __repr__(self): 66 | return "" 67 | 68 | def utcoffset(self, dt): 69 | if self._isdst(dt): 70 | return self.DSTOFFSET 71 | else: 72 | return self.STDOFFSET 73 | 74 | def dst(self, dt): 75 | if self._isdst(dt): 76 | return self.DSTDIFF 77 | else: 78 | return ZERO 79 | 80 | def tzname(self, dt): 81 | return _time.tzname[self._isdst(dt)] 82 | 83 | def _isdst(self, dt): 84 | tt = (dt.year, dt.month, dt.day, 85 | dt.hour, dt.minute, dt.second, 86 | dt.weekday(), 0, 0) 87 | stamp = _time.mktime(tt) 88 | tt = _time.localtime(stamp) 89 | return tt.tm_isdst > 0 90 | 91 | 92 | utc = pytz.utc if pytz else UTC() 93 | """UTC time zone as a tzinfo instance.""" 94 | 95 | # ### # In order to avoid accessing the settings at compile time, 96 | # ### # wrap the expression in a function and cache the result. 97 | # ### _localtime = None 98 | # ### 99 | # ### def get_default_timezone(): 100 | # ### """ 101 | # ### Returns the default time zone as a tzinfo instance. 102 | # ### 103 | # ### This is the time zone defined by settings.TIME_ZONE. 104 | # ### 105 | # ### See also :func:`get_current_timezone`. 106 | # ### """ 107 | # ### global _localtime 108 | # ### if _localtime is None: 109 | # ### if isinstance(settings.TIME_ZONE, str) and pytz is not None: 110 | # ### _localtime = pytz.timezone(settings.TIME_ZONE) 111 | # ### else: 112 | # ### _localtime = LocalTimezone() 113 | # ### return _localtime 114 | 115 | # This function exists for consistency with get_current_timezone_name 116 | def get_default_timezone_name(): 117 | """ 118 | Returns the name of the default time zone. 119 | """ 120 | return _get_timezone_name(get_default_timezone()) 121 | 122 | _active = local() 123 | 124 | def get_current_timezone(): 125 | """ 126 | Returns the currently active time zone as a tzinfo instance. 127 | """ 128 | return getattr(_active, "value", get_default_timezone()) 129 | 130 | def get_current_timezone_name(): 131 | """ 132 | Returns the name of the currently active time zone. 133 | """ 134 | return _get_timezone_name(get_current_timezone()) 135 | 136 | def _get_timezone_name(timezone): 137 | """ 138 | Returns the name of ``timezone``. 139 | """ 140 | try: 141 | # for pytz timezones 142 | return timezone.zone 143 | except AttributeError: 144 | # for regular tzinfo objects 145 | local_now = datetime.now(timezone) 146 | return timezone.tzname(local_now) 147 | 148 | # Timezone selection functions. 149 | 150 | # These functions don't change os.environ['TZ'] and call time.tzset() 151 | # because it isn't thread safe. 152 | 153 | def activate(timezone): 154 | """ 155 | Sets the time zone for the current thread. 156 | 157 | The ``timezone`` argument must be an instance of a tzinfo subclass or a 158 | time zone name. If it is a time zone name, pytz is required. 159 | """ 160 | if isinstance(timezone, tzinfo): 161 | _active.value = timezone 162 | elif isinstance(timezone, (str, )) and pytz is not None: 163 | _active.value = pytz.timezone(timezone) 164 | else: 165 | raise ValueError("Invalid timezone: %r" % timezone) 166 | 167 | def deactivate(): 168 | """ 169 | Unsets the time zone for the current thread. 170 | 171 | Django will then use the time zone defined by settings.TIME_ZONE. 172 | """ 173 | if hasattr(_active, "value"): 174 | del _active.value 175 | 176 | class override: 177 | """ 178 | Temporarily set the time zone for the current thread. 179 | 180 | This is a context manager that uses ``~django.utils.timezone.activate()`` 181 | to set the timezone on entry, and restores the previously active timezone 182 | on exit. 183 | 184 | The ``timezone`` argument must be an instance of a ``tzinfo`` subclass, a 185 | time zone name, or ``None``. If is it a time zone name, pytz is required. 186 | If it is ``None``, Django enables the default time zone. 187 | """ 188 | def __init__(self, timezone): 189 | self.timezone = timezone 190 | self.old_timezone = getattr(_active, 'value', None) 191 | 192 | def __enter__(self): 193 | if self.timezone is None: 194 | deactivate() 195 | else: 196 | activate(self.timezone) 197 | 198 | def __exit__(self, exc_type, exc_value, traceback): 199 | if self.old_timezone is not None: 200 | _active.value = self.old_timezone 201 | else: 202 | del _active.value 203 | 204 | 205 | # ### # Templates 206 | # ### 207 | # ### def template_localtime(value, use_tz=None): 208 | # ### """ 209 | # ### Checks if value is a datetime and converts it to local time if necessary. 210 | # ### 211 | # ### If use_tz is provided and is not None, that will force the value to 212 | # ### be converted (or not), overriding the value of settings.USE_TZ. 213 | # ### 214 | # ### This function is designed for use by the template engine. 215 | # ### """ 216 | # ### should_convert = (isinstance(value, datetime) 217 | # ### and (settings.USE_TZ if use_tz is None else use_tz) 218 | # ### and not is_naive(value) 219 | # ### and getattr(value, 'convert_to_local_time', True)) 220 | # ### return localtime(value) if should_convert else value 221 | 222 | 223 | # Utilities 224 | 225 | def localtime(value, timezone=None): 226 | """ 227 | Converts an aware datetime.datetime to local time. 228 | 229 | Local time is defined by the current time zone, unless another time zone 230 | is specified. 231 | """ 232 | if timezone is None: 233 | timezone = get_current_timezone() 234 | value = value.astimezone(timezone) 235 | if hasattr(timezone, 'normalize'): 236 | # available for pytz time zones 237 | value = timezone.normalize(value) 238 | return value 239 | 240 | # ### def now(): 241 | # ### """ 242 | # ### Returns an aware or naive datetime.datetime, depending on settings.USE_TZ. 243 | # ### """ 244 | # ### if settings.USE_TZ: 245 | # ### # timeit shows that datetime.now(tz=utc) is 24% slower 246 | # ### return datetime.utcnow().replace(tzinfo=utc) 247 | # ### else: 248 | # ### return datetime.now() 249 | 250 | # By design, these four functions don't perform any checks on their arguments. 251 | # The caller should ensure that they don't receive an invalid value like None. 252 | 253 | def is_aware(value): 254 | """ 255 | Determines if a given datetime.datetime is aware. 256 | 257 | The logic is described in Python's docs: 258 | http://docs.python.org/library/datetime.html#datetime.tzinfo 259 | """ 260 | return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None 261 | 262 | def is_naive(value): 263 | """ 264 | Determines if a given datetime.datetime is naive. 265 | 266 | The logic is described in Python's docs: 267 | http://docs.python.org/library/datetime.html#datetime.tzinfo 268 | """ 269 | return value.tzinfo is None or value.tzinfo.utcoffset(value) is None 270 | 271 | def make_aware(value, timezone): 272 | """ 273 | Makes a naive datetime.datetime in a given time zone aware. 274 | """ 275 | if hasattr(timezone, 'localize'): 276 | # available for pytz time zones 277 | return timezone.localize(value, is_dst=None) 278 | else: 279 | # may be wrong around DST changes 280 | return value.replace(tzinfo=timezone) 281 | 282 | def make_naive(value, timezone): 283 | """ 284 | Makes an aware datetime.datetime naive in a given time zone. 285 | """ 286 | value = value.astimezone(timezone) 287 | if hasattr(timezone, 'normalize'): 288 | # available for pytz time zones 289 | value = timezone.normalize(value) 290 | return value.replace(tzinfo=None) 291 | -------------------------------------------------------------------------------- /feedgenerator/django/utils/xmlutils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for XML generation/parsing. 3 | """ 4 | 5 | from xml.sax.saxutils import XMLGenerator, quoteattr 6 | 7 | class SimplerXMLGenerator(XMLGenerator): 8 | def addQuickElement(self, name, contents=None, attrs=None): 9 | "Convenience method for adding an element with no children" 10 | if attrs is None: attrs = {} 11 | self.startElement(name, attrs) 12 | if contents is not None: 13 | self.characters(contents) 14 | self.endElement(name) 15 | 16 | def startElement(self, name, attrs): 17 | self._write('<' + name) 18 | # sort attributes for consistent output 19 | for (name, value) in sorted(attrs.items()): 20 | self._write(f' {name}={quoteattr(value)}') 21 | self._write('>') 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "feedgenerator" 3 | version = "2.1.0" 4 | authors = [ 5 | {name="Pelican Dev Team", email="authors@getpelican.com"}, 6 | ] 7 | description = "Standalone version of django.utils.feedgenerator" 8 | keywords = ["feed", "atom", "rss"] 9 | readme = "README.rst" 10 | license = { file="LICENSE" } 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Environment :: Web Environment", 14 | "Framework :: Pelican", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Internet :: WWW/HTTP", 25 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | requires-python = ">=3.8.1" 29 | dependencies = [ 30 | "pytz>=0a", 31 | ] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/getpelican/feedgenerator" 35 | "Issue Tracker" = "https://github.com/getpelican/feedgenerator/issues" 36 | Funding = "https://donate.getpelican.com/" 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "pytest", 41 | "pytest-cov", 42 | ] 43 | 44 | [tool.autopub] 45 | project-name = "FeedGenerator" 46 | git-username = "botpub" 47 | git-email = "52496925+botpub@users.noreply.github.com" 48 | build-system = "setuptools" 49 | 50 | [tool.pytest.ini_options] 51 | addopts = """ 52 | --cov=feedgenerator 53 | --cov=tests 54 | --cov-report=html 55 | --cov-report=term-missing:skip-covered 56 | """ 57 | 58 | [tool.setuptools] 59 | packages = [ 60 | "feedgenerator", 61 | "feedgenerator.django", 62 | "feedgenerator.django.utils", 63 | ] 64 | 65 | [build-system] 66 | requires = ["setuptools>=64.0"] 67 | build-backend = "setuptools.build_meta" 68 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getpelican/feedgenerator/c1e47f427d95ec71d5cfe651b268b063e5788741/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_feedgenerator.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | import feedgenerator 6 | 7 | 8 | FIXT_FEED = dict( 9 | title="Poynter E-Media Tidbits", 10 | link="http://www.poynter.org/column.asp?id=31", 11 | description="""A group Weblog by the sharpest minds in online media/journalism/publishing. 12 | Umlauts: äöüßÄÖÜ 13 | Chinese: 老师是四十四,是不是? 14 | Finnish: Mustan kissan paksut posket. (ah, no special chars) Kärpänen sanoi kärpäselle: tuu kattoon kattoon ku kaveri tapettiin tapettiin. 15 | """, 16 | language="en" 17 | ) 18 | FIXT_ITEM = dict( 19 | title="Hello", 20 | link="http://www.holovaty.com/täst/", 21 | description="Testing.", 22 | content="Full content of our testing entry.", 23 | pubdate=datetime.datetime(2016,8,11,0,0,0,0), 24 | ) 25 | 26 | 27 | EXPECTED_RESULT_RSS = """ 28 | Poynter E-Media Tidbitshttp://www.poynter.org/column.asp?id=31A group Weblog by the sharpest minds in online media/journalism/publishing. 29 | Umlauts: äöüßÄÖÜ 30 | Chinese: 老师是四十四,是不是? 31 | Finnish: Mustan kissan paksut posket. (ah, no special chars) Kärpänen sanoi kärpäselle: tuu kattoon kattoon ku kaveri tapettiin tapettiin. 32 | en%DATE%Hellohttp://www.holovaty.com/t%C3%A4st/Testing.Thu, 11 Aug 2016 00:00:00 -0000""" 33 | 34 | EXPECTED_RESULT_ATOM = """ 35 | Poynter E-Media Tidbitshttp://www.poynter.org/column.asp?id=31%DATE%A group Weblog by the sharpest minds in online media/journalism/publishing. 36 | Umlauts: äöüßÄÖÜ 37 | Chinese: 老师是四十四,是不是? 38 | Finnish: Mustan kissan paksut posket. (ah, no special chars) Kärpänen sanoi kärpäselle: tuu kattoon kattoon ku kaveri tapettiin tapettiin. 39 | Hello2016-08-11T00:00:00Z2016-08-11T00:00:00Ztag:www.holovaty.com,2016-08-11:/t%C3%A4st/Testing.Full content of our testing entry.""" 40 | 41 | ENCODING = 'utf-8' 42 | 43 | def build_expected_rss_result(feed, expected_result, encoding): 44 | # Result's date is of course different from the date in the fixture. 45 | # So make them equal! 46 | d = feedgenerator.rfc2822_date(feed.latest_post_date()) 47 | s = expected_result.replace('%DATE%', d) 48 | if encoding: 49 | return s.encode(encoding) 50 | else: 51 | return s 52 | 53 | 54 | def build_expected_atom_result(feed, expected_result, encoding): 55 | # Result's date is of course different from the date in the fixture. 56 | # So make them equal! 57 | d = feedgenerator.rfc3339_date(feed.latest_post_date()) 58 | s = expected_result.replace('%DATE%', d) 59 | if encoding: 60 | return s.encode(encoding) 61 | else: 62 | return s 63 | 64 | 65 | def test_000_types(): 66 | for k, v in FIXT_FEED.items(): 67 | assert isinstance(v, str) 68 | for k, v in FIXT_ITEM.items(): 69 | if k == "pubdate" or k == "updateddate": 70 | assert isinstance(v, datetime.datetime) 71 | else: 72 | assert isinstance(v, str) 73 | assert isinstance(EXPECTED_RESULT_RSS, str) 74 | 75 | 76 | def test_001_string_results_rss(): 77 | #import ipdb; ipdb.set_trace() 78 | feed = feedgenerator.Rss201rev2Feed(**FIXT_FEED) 79 | feed.add_item(**FIXT_ITEM) 80 | result = feed.writeString(ENCODING) 81 | # On Python 3, result of feedgenerator is a unicode string! 82 | # So do not encode our expected_result. 83 | expected_result = build_expected_rss_result(feed, EXPECTED_RESULT_RSS, None) 84 | assert isinstance(result, type(expected_result)) 85 | assert result == expected_result 86 | 87 | 88 | def test_002_string_results_atom(): 89 | #import ipdb; ipdb.set_trace() 90 | feed = feedgenerator.Atom1Feed(**FIXT_FEED) 91 | feed.add_item(**FIXT_ITEM) 92 | result = feed.writeString(ENCODING) 93 | # On Python 3, result of feedgenerator is a unicode string! 94 | # So do not encode our expected_result. 95 | expected_result = build_expected_atom_result(feed, EXPECTED_RESULT_ATOM, None) 96 | assert isinstance(result, type(expected_result)) 97 | assert result == expected_result 98 | 99 | 100 | @pytest.mark.parametrize("description, subtitle, fragment, nonfragment", [ 101 | # Neither description nor subtitle are provided 102 | (None, None, None, ""), 103 | ("", "", None, ""), 104 | # Description is provided 105 | ("description", None, "description", None), 106 | ("description", "", "description", None), 107 | # Subtitle is provided 108 | (None, "subtitle", "subtitle", None), 109 | ("", "subtitle", "subtitle", None), 110 | # Both description & subtitle are provided; subtitle takes precedence 111 | ("description", "subtitle", "subtitle", "description"), 112 | ]) 113 | def test_subtitle(description, subtitle, fragment, nonfragment): 114 | """Test regression for https://github.com/getpelican/feedgenerator/issues/30. 115 | 116 | We test against all four possible combinations of description x 117 | subtitle parameters and additionally for None and "". 118 | 119 | description, subtitle are the values for the respective 120 | feed-parameters. 121 | 122 | fragment and nonfragment are text fragments that should be in the 123 | expected result or not. 124 | 125 | """ 126 | FIXT_FEED = dict( 127 | title="title", 128 | link="https://example.com", 129 | description=description, 130 | subtitle=subtitle, 131 | ) 132 | feed = feedgenerator.Atom1Feed(**FIXT_FEED) 133 | result = feed.writeString(ENCODING) 134 | if fragment: 135 | assert fragment in result 136 | if nonfragment: 137 | assert nonfragment not in result 138 | -------------------------------------------------------------------------------- /tests/test_stringio.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | ENCODING = 'utf-8' 4 | 5 | S0 = 'hello world, Umlauts: äöüßÄÖÜ, Chinese: 四是四,十是十,十四是十四,四十是四十,四十四隻不識字之石獅子是死的' 6 | S0_BYTES = 'fe fi foe fam'.encode(ENCODING) 7 | 8 | #print("###", StringIO, "###") 9 | 10 | 11 | def test_001_text(): 12 | # If we throw unicode into the StringIO buffer, we'll 13 | # get unicode out of it. 14 | assert isinstance(S0, str) 15 | buf = StringIO() 16 | print(S0, file=buf, end="") 17 | s1 = buf.getvalue() 18 | assert isinstance(S0, type(s1)) 19 | assert S0 == s1 20 | assert isinstance(s1, str) 21 | 22 | 23 | def test_002_bytes(): 24 | buf = StringIO() 25 | print(S0_BYTES, file=buf, end="") 26 | s1 = buf.getvalue() 27 | 28 | # In Python 3 StringIO *ALWAYS* returns str (=text=unicode) ! 29 | # Even if we originally write bytes into the buffer, the value 30 | # we get out of it has type str! 31 | 32 | # Input is bytes 33 | assert isinstance(S0_BYTES, bytes) 34 | # Output is NOT bytes... 35 | assert not isinstance(S0_BYTES, type(s1)) 36 | assert not isinstance(s1, bytes) 37 | # ...but str! 38 | assert isinstance(s1, str) 39 | # So the contents are not equal! 40 | assert S0_BYTES != s1 41 | # StringIO coerced bytes into str: 42 | # b'xyz' ---> "b'xyz'" 43 | assert str(S0_BYTES) == s1 44 | # See, the type info is literally present in the output str! 45 | assert "b'" + str(S0_BYTES, encoding=ENCODING) + "'" == s1 46 | # Coercion is NOT decoding! 47 | assert S0_BYTES.decode(ENCODING) != s1 48 | assert str(S0_BYTES, encoding=ENCODING) != s1 49 | # These are the same 50 | assert S0_BYTES.decode(ENCODING) == str(S0_BYTES, encoding=ENCODING) 51 | # Additional note: 52 | # If we do not specify an encoding when we create a StringIO 53 | # buffer, Python 3 automatically uses the locale's preferred 54 | # encoding: locale.getpreferredencoding() 55 | # Cf. http://docs.python.org/release/3.0.1/library/io.html#io.TextIOWrapper 56 | # In my case this is the same encoding as the encoding of this source file, 57 | # namely UTF-8. If on your system both encodings are different, you may 58 | # encounter other results than the above. 59 | # 60 | # In Python 3.2 the signature of StringIO() has changed. It is no more 61 | # possible to specify an encoding here. 62 | -------------------------------------------------------------------------------- /tests/usage_example.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import feedgenerator 4 | 5 | feed = feedgenerator.Rss201rev2Feed( 6 | title="Poynter E-Media Tidbits", 7 | link="http://www.poynter.org/column.asp?id=31", 8 | description="""A group Weblog by the sharpest minds in online media/journalism/publishing. 9 | Umlauts: äöüßÄÖÜ 10 | Chinese: 老师是四十四,是不是? 11 | Finnish: Mustan kissan paksut posket. (ah, no special chars) Kärpänen sanoi kärpäselle: tuu kattoon kattoon ku kaveri tapettiin tapettiin. 12 | """, 13 | language="en", 14 | ) 15 | feed.add_item( 16 | title="Hello", 17 | link="http://www.holovaty.com/test/", 18 | description="Testing." 19 | ) 20 | 21 | FN_PREFIX = 'feed_py3-' 22 | 23 | # Usage example in feedgenerator docs opens the file in text mode, not binary. 24 | # So we do this here likewise. 25 | fd, filename = tempfile.mkstemp(prefix=FN_PREFIX, suffix='.txt', text=True) 26 | try: 27 | fh = os.fdopen(fd, 'w') 28 | feed.write(fh, 'utf-8') 29 | finally: 30 | fh.close() 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{3.8,3.9,3.10,3.11,3.12} 3 | 4 | [testenv] 5 | basepython = 6 | py3.8: python3.8 7 | py3.9: python3.9 8 | py3.10: python3.10 9 | py3.11: python3.11 10 | py3.12: python3.12 11 | commands = 12 | {envpython} --version 13 | pytest 14 | deps = 15 | pytest 16 | pytest-cov 17 | --------------------------------------------------------------------------------