├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .render-buildpacks.json ├── .travis.yml ├── Dockerfile.render ├── LICENSE ├── Procfile ├── README.md ├── fix_authors.py ├── render.yaml ├── requirements.txt ├── runtime.txt └── sympy_bot ├── __init__.py ├── __main__.py ├── changelog.py ├── submodules.txt ├── tests ├── Release-Notes-for-1.2.md ├── __init__.py ├── test_changelog.py ├── test_get_changelog.py ├── test_update_release_notes.py ├── test_update_wiki.py ├── test_util_functions.py └── test_webapp.py ├── update_wiki.py └── webapp.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ['3.9'] 9 | fail-fast: false 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | - name: Install Dependencies 16 | run: | 17 | pip install pyflakes pytest gidgethub aiohttp requests pytest-aiohttp pytest-mock 18 | - name: Run Tests 19 | run: | 20 | pyflakes . 21 | pytest 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file tells git what files to ignore (e.g., you won't see them as 2 | # untracked with "git status"). Add anything to it that can be cleared 3 | # without any worry (e.g., by "git clean -Xdf"), because it can be 4 | # regenerated. Lines beginning with # are comments. You can also ignore 5 | # files on a per-repository basis by modifying the core.excludesfile 6 | # configuration option (see "git help config"). If you need to make git 7 | # track a file that is ignored for some reason, you have to use 8 | # "git add -f". See "git help gitignore" for more information. 9 | 10 | 11 | # Regular Python bytecode file 12 | *.pyc 13 | 14 | # Optimized Python bytecode file 15 | *.pyo 16 | 17 | # Vim's swap files 18 | *.sw[op] 19 | 20 | # Generated files from Jython 21 | *$py.class 22 | 23 | # Generated C files in the polys directory 24 | /sympy/polys/*.c 25 | 26 | # Generated dynamic libraries in the polys directory 27 | /sympy/polys/*.so 28 | 29 | # File generated by setup.py using MANIFEST.in 30 | MANIFEST 31 | 32 | # Generated by ctags (used to improve autocompletion in vim) 33 | tags 34 | 35 | my/ 36 | 37 | # Files generated by setup.py 38 | dist/ 39 | build/ 40 | 41 | # Files generated by setupegg.py 42 | sympy.egg-info/ 43 | 44 | # Tox files 45 | tox.ini 46 | .tox/ 47 | 48 | # Coverage files (./bin/coverage_report.py) 49 | .coverage 50 | covhtml/ 51 | 52 | # Built doc files (cd doc; make html) 53 | doc/_build/ 54 | doc/sphinx/ 55 | 56 | # pdf files generated from svg files (cd doc; make latex) 57 | doc/src/modules/physics/mechanics/*.pdf 58 | doc/src/modules/physics/vector/*.pdf 59 | 60 | # Mac OS X Junk 61 | .DS_Store 62 | 63 | # Backup files 64 | *~ 65 | 66 | # Temp output of sympy/printing/preview.py: 67 | sample.tex 68 | 69 | # IPython Notebook Checkpoints 70 | .ipynb_checkpoints/ 71 | 72 | # pytest cache folder 73 | .*cache 74 | 75 | # pytest related data file for slow tests 76 | .ci/durations.log 77 | -------------------------------------------------------------------------------- /.render-buildpacks.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildpacks": [ 3 | "heroku/python" 4 | ] 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | python: 5 | - 3.9 6 | 7 | install: 8 | - pip install pyflakes pytest gidgethub aiohttp requests pytest-aiohttp pytest-mock 9 | 10 | script: 11 | - pyflakes . 12 | - pytest 13 | -------------------------------------------------------------------------------- /Dockerfile.render: -------------------------------------------------------------------------------- 1 | # "v4-" stacks use our new, more rigorous buildpacks management system. They 2 | # allow you to use multiple buildpacks in a single application, as well as to 3 | # use custom buildpacks. 4 | # 5 | # - `v2-` images work with heroku-import v3.x. 6 | # - `v4-` images work with heroku-import v4.x. (We synced the tags.) 7 | 8 | ARG IMPORT_VERSION=v4 9 | ARG HEROKU_STACK=${IMPORT_VERSION}-heroku-20 10 | FROM ghcr.io/renderinc/heroku-app-builder:${HEROKU_STACK} AS builder 11 | 12 | 13 | # Below, please specify any build-time environment variables that you need to 14 | # reference in your build (as called by your buildpacks). If you don't specify 15 | # the arg below, you won't be able to access it in your build. You can also 16 | # specify a default value, as with any Docker `ARG`, if appropriate for your 17 | # use case. 18 | 19 | # ARG MY_BUILD_TIME_ENV_VAR 20 | # ARG DATABASE_URL 21 | 22 | # The FROM statement above refers to an image with the base buildpacks already 23 | # in place. We then run the apply-buildpacks.py script here because, unlike our 24 | # `v2` image, this allows us to expose build-time env vars to your app. 25 | RUN /render/build-scripts/apply-buildpacks.py ${HEROKU_STACK} 26 | 27 | # We strongly recommend that you package a Procfile with your application, but 28 | # if you don't, we'll try to guess one for you. If this is incorrect, please 29 | # add a Procfile that tells us what you need us to run. 30 | RUN if [[ -f /app/Procfile ]]; then \ 31 | /render/build-scripts/create-process-types "/app/Procfile"; \ 32 | fi; 33 | 34 | # For running the app, we use a clean base image and also one without Ubuntu development packages 35 | # https://devcenter.heroku.com/articles/heroku-20-stack#heroku-20-docker-image 36 | FROM ghcr.io/renderinc/heroku-app-runner:${HEROKU_STACK} AS runner 37 | 38 | # Here we copy your build artifacts from the build image to the runner so that 39 | # the image that we deploy to Render is smaller and, therefore, can start up 40 | # faster. 41 | COPY --from=builder --chown=1000:1000 /render /render/ 42 | COPY --from=builder --chown=1000:1000 /app /app/ 43 | 44 | # Here we're switching to a non-root user in the container to remove some categories 45 | # of container-escape attack. 46 | USER 1000:1000 47 | WORKDIR /app 48 | 49 | # This sources all /app/.profile.d/*.sh files before process start. 50 | # These are created by buildpacks, and you probably don't have to worry about this. 51 | # https://devcenter.heroku.com/articles/buildpack-api#profile-d-scripts 52 | ENTRYPOINT [ "/render/setup-env" ] 53 | 54 | # 3. By default, we run the 'web' process type defined in the app's Procfile 55 | # You may override the process type that is run by replacing 'web' with another 56 | # process type name in the CMD line below. That process type must have been 57 | # defined in the app's Procfile during build. 58 | CMD [ "/render/process/web" ] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 SymPy Development Team 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | a. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | b. 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 | c. Neither the name of SymPy nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 26 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 27 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 28 | DAMAGE. 29 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: python3 -m sympy_bot 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SymPy Bot 2 | 3 | This is a GitHub bot for SymPy. It runs on Render and uses the 4 | [@sympy-bot](https://github.com/sympy-bot) GitHub user. 5 | 6 | The bot makes sure that every pull request to SymPy has a release notes entry 7 | in the pull request description. It then automatically adds these notes to the 8 | [release notes](https://github.com/sympy/sympy/wiki/Release-Notes) on the wiki 9 | when the pull request is merged. 10 | 11 | See [the guide on the SymPy 12 | wiki](https://github.com/sympy/sympy/wiki/Writing-Release-Notes) on how to 13 | write release notes. 14 | 15 | The bot may also do other things in the future. If you have any suggestions or 16 | have found any bugs, please open an 17 | [issue](https://github.com/sympy/sympy-bot/issues). Pull requests are welcome 18 | too. 19 | 20 | ## Setting up the bot 21 | 22 | [This tutorial](https://github-bot-tutorial.readthedocs.io/en/latest) is very 23 | good on how to set up Heroku and write GitHub bots. This bot is based on it, 24 | although it uses Render instead of Heroku. 25 | 26 | The bot is tied to Render, so any push to this repo is automatically deployed 27 | there. The Redner 28 | dashboard is at https://dashboard.render.com/ 29 | 30 | If the bot stops working, look at the logs on the Render dashboard. 31 | 32 | Next, you need to set up the bot on GitHub. To do so, follow these steps: 33 | 34 | 1. Go to the webhooks settings (for instance, at 35 | https://github.com/sympy/sympy/settings/hooks), and create a new webhook. 36 | 37 | - Set the payload URL to the Render app URL (for instance, 38 | https://sympy-bot.onrender.com/) 39 | - Set the content type to `application/json` 40 | - Generate a random password for the secret. I used the keychain app on my 41 | Mac to generate a 20 character password with random characters. Save this 42 | secret, as you will need to enter it in Render as well. 43 | - Under "Which events would you like to trigger this webhook?" select "Let 44 | me select individual events.". Then make sure only **Pull requests** is 45 | checked. 46 | - Make sure **Active** is checked 47 | 48 | 2. Go to the Render dashboard and click on "Environment" on the left side. 49 | Create two config variables: 50 | 51 | - `GH_SECRET`: set this to the secret you created in step 1 above 52 | - `GH_AUTH`: set this to the personal access token for the `sympy-bot` 53 | user. If you don't have this or need to regenerate it, login as the bot 54 | user and go to the personal access token settings (at 55 | https://github.com/settings/tokens), and create a new token. **VERY 56 | IMPORTANT:** Give the token `public_repo` access only. 57 | 58 | 3. Give the `sympy-bot` user push access to the repo. This is required for the 59 | bot to set commit statuses and to push to the wiki. If you know how to 60 | allow it to do this without giving it as much access, please let me know. I 61 | have tried playing with using reviews instead of statuses, but I couldn't 62 | get it to work. 63 | 64 | 65 | ## Testing 66 | 67 | To test, push to a separate branch (`master` has branch protection) on this 68 | repo (you can also set up a separate testing deploy for your fork if you 69 | want). Then go to the Render dashboard and manually deploy the branch. We may 70 | at some point enable automatic deployments for PRs in Render. 71 | 72 | ### Debugging Webhooks 73 | 74 | To debug webhooks, you can go to the webhooks settings for the repo the bot is 75 | set up on (e.g., https://github.com/sympy/sympy/settings/hooks), and click the 76 | webhook for https://sympy-bot.onrender.com/. This will show you all recent 77 | webhooks that were delivered, with the exact JSON that was delivered as well 78 | as the headers and the response. Each webhook has a corresponding UUID (the 79 | delivery id), which is printed by the bot in the logs when it receives it. 80 | 81 | ## Rate Limits 82 | 83 | GitHub has a rate limit of 5000 requests per hour. A single bot action may 84 | result in multiple API requests. You can see the current rate limit and when 85 | it resets at https://sympy-bot.onrender.com/. If the bot detects that its 86 | rate limits are getting very low, it will post a warning comment on a pull 87 | request. Right now, the bot doesn't use the API very much, so we never get 88 | near the rate limits, unless someone were to attempt to spam it. However, in 89 | the future, this could become an issue if the bot is made to do more stuff. 90 | -------------------------------------------------------------------------------- /fix_authors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Script to check which pull requests listed in the release notes have multiple 4 | authors. Prior to https://github.com/sympy/sympy-bot/pull/51, the bot was not 5 | posting multiple authors to the wiki correctly, so this script helps to 6 | retroactively fix that. 7 | 8 | This requires doctr for the GitHub login functionality. 9 | """ 10 | 11 | import sys 12 | import re 13 | 14 | import requests 15 | 16 | from sympy_bot.changelog import format_authors 17 | 18 | from doctr.local import GitHub_login, GitHub_raise_for_status 19 | 20 | def reauth_GitHub_raise_for_status(r, login_kwargs): 21 | if r.status_code == 401 and r.headers.get('X-GitHub-OTP'): 22 | auth = login_kwargs['auth'] 23 | print("You must provide a new 2FA code") 24 | login_kwargs.update(GitHub_login(username=auth.username, password=auth.password)) 25 | else: 26 | GitHub_raise_for_status(r) 27 | 28 | def get(url, kwargs): 29 | while True: 30 | r = requests.get(url, **kwargs) 31 | reauth_GitHub_raise_for_status(r, kwargs) 32 | if r.status_code == 401 and r.headers.get('X-GitHub-OTP'): 33 | continue 34 | return r 35 | 36 | def main(): 37 | if len(sys.argv) != 2 or sys.argv[1] in ['-h', '--help']: 38 | print("Provide the path to the release notes page you want to fix.") 39 | print("You will need to clone the SymPy wiki repo (git clone git@github.com:sympy/sympy.wiki.git).") 40 | sys.exit(1) 41 | 42 | release_notes_file = sys.argv[1] 43 | 44 | with open(release_notes_file) as f: 45 | release_notes = f.read() 46 | 47 | PRs = set() 48 | for m in re.finditer(r'https://github.com/sympy/sympy/pull/(\d+)', release_notes): 49 | PRs.add(m.group(1)) 50 | 51 | login_kwargs = GitHub_login() 52 | 53 | print(f"Found {len(PRs)} PRs, from #{min(PRs, key=int)} to #{max(PRs, key=int)}") 54 | 55 | pr_users = {} 56 | for i, pr in enumerate(sorted(PRs, key=int)): 57 | print(f"Getting PR #{pr}: {i+1}/{len(PRs)}") 58 | pull_request = get(f'https://api.github.com/repos/sympy/sympy/pulls/{pr}', login_kwargs) 59 | 60 | users = set() 61 | commits_url = pull_request.json()['commits_url'] 62 | commits = get(commits_url, login_kwargs) 63 | for commit in commits.json(): 64 | if commit['author']: 65 | users.add(commit['author']['login']) 66 | 67 | users.add(pull_request.json()['head']['user']['login']) 68 | 69 | pr_users[pr] = users 70 | 71 | for pr in sorted(pr_users, key=int): 72 | users = pr_users[pr] 73 | if len(users) > 1: 74 | authors = format_authors(sorted(users, key=str.lower)) 75 | print(f"Fixing authors for #{pr}: {authors}") 76 | release_re = rf'(?m)(\(\[#{pr}\]\(https://github.com/sympy/sympy/pull/{pr}\) by ).*\)$' 77 | repl = rf'\1{authors})' 78 | release_notes, n = re.subn(release_re, repl, release_notes) 79 | if n == 0: 80 | print(f"WARNING: Could not fix the authors for PR #{pr}.") 81 | 82 | print("Updating the release notes file.") 83 | with open(release_notes_file, 'w') as f: 84 | f.write(release_notes) 85 | 86 | if __name__ == '__main__': 87 | sys.exit(main()) 88 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | # This file was generated by Render's heroku-import Heroku CLI plugin 2 | # https://www.npmjs.com/package/@renderinc/heroku-import 3 | # Schema documented at https://render.com/docs/yaml-spec 4 | services: 5 | - type: web # valid values: https://render.com/docs/yaml-spec#type 6 | name: sympy-bot 7 | env: docker # valid values: https://render.com/docs/yaml-spec#environment 8 | dockerfilePath: Dockerfile.render 9 | plan: free # optional; defaults to starter 10 | numInstances: 1 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | aiohttp 3 | gidgethub 4 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.9.16 2 | -------------------------------------------------------------------------------- /sympy_bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .changelog import (get_valid_headers, get_changelog, 2 | get_release_notes_filename, update_release_notes, format_change) 3 | 4 | __all__ = ['get_valid_headers', 'get_changelog', 'get_release_notes_filename', 5 | 'update_release_notes', 'format_change'] 6 | -------------------------------------------------------------------------------- /sympy_bot/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from aiohttp import web 4 | 5 | from .webapp import main_get, main_post 6 | 7 | if __name__ == "__main__": 8 | app = web.Application() 9 | app.router.add_post("/", main_post) 10 | app.router.add_get("/", main_get) 11 | port = os.environ.get("PORT") 12 | if port is not None: 13 | port = int(port) 14 | 15 | web.run_app(app, port=port) 16 | -------------------------------------------------------------------------------- /sympy_bot/changelog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import textwrap 5 | from collections import defaultdict 6 | 7 | PREFIX = '* ' 8 | SUFFIX = '{indent}([#{pr_number}](https://github.com/sympy/sympy/pull/{pr_number}) by {authors})\n' 9 | AUTHOR = "[@{author}](https://github.com/{author})" 10 | VERSION_RE = re.compile(r'\d+(?:(?:\.\d+)*(?:\.[1-9]\d*)|\.0)') 11 | BEGIN_RELEASE_NOTES = "" 12 | END_RELEASE_NOTES = "" 13 | 14 | def get_valid_headers(): 15 | valid_headers = [] 16 | with open(os.path.join(os.path.dirname(__file__), 'submodules.txt')) as f: 17 | for line in f.readlines(): 18 | if line and not line.startswith('#'): 19 | valid_headers.append(line.strip()) 20 | 21 | return valid_headers 22 | 23 | def is_bullet(line): 24 | l = line.lstrip() 25 | return l.startswith('* ') or l.startswith('- ') or l.startswith('+ ') 26 | 27 | def get_changelog(pr_desc): 28 | """ 29 | Parse changelogs from a string 30 | 31 | pr_desc should be a string or list or lines of the pull request 32 | description. 33 | 34 | Returns a tuple (status, message, changelogs), where: 35 | 36 | - status is a boolean indicating if the changelog entry is valid or not 37 | - message is a string with a message to be reported changelogs is a dict 38 | - mapping headers to changelog messages 39 | 40 | """ 41 | status = True 42 | message_list = [] 43 | changelogs = defaultdict(list) 44 | header = None 45 | if isinstance(pr_desc, (str, bytes)): 46 | pr_desc = pr_desc.splitlines() 47 | lines = iter(pr_desc) 48 | valid_headers = get_valid_headers() 49 | 50 | # First find the release notes header 51 | for line in lines: 52 | if line.strip() == BEGIN_RELEASE_NOTES: 53 | break 54 | else: 55 | return (False, f"* The `{BEGIN_RELEASE_NOTES}` block was not found", 56 | changelogs) 57 | 58 | prefix = ' ' 59 | for line in lines: 60 | if not header and not line.strip(): 61 | continue 62 | if line.strip() == END_RELEASE_NOTES: 63 | break 64 | if line.strip() == 'NO ENTRY': 65 | message_list += ["* No release notes entry will be added for this pull request."] 66 | changelogs.clear() 67 | status = True 68 | break 69 | elif line.startswith('* ') or line.startswith('- ') or line.startswith('+ '): 70 | header = line.lstrip('*-+ ') 71 | header = header.strip() 72 | if header not in valid_headers: 73 | status = False 74 | if ' ' in header: 75 | # Most likely just forgot the header 76 | message_list += [ 77 | "* Release notes must include a header. Please use the submodule name as the header, like", 78 | " ```", 79 | " * core", 80 | " * made Add faster", 81 | "", 82 | " * printing", 83 | " * improve LaTeX printing of fractions", 84 | " ```", 85 | "", 86 | " See [here](https://github.com/sympy/sympy-bot/blob/master/sympy_bot/submodules.txt) for a list of valid release notes headers.", 87 | ] 88 | else: 89 | message_list += [ 90 | "* `%s` is not a valid release notes header. Release notes headers should be SymPy submodule names, like" % header, 91 | "", 92 | " ```", 93 | " * core", 94 | " * made Add faster", 95 | "", 96 | " * printing", 97 | " * improve LaTeX printing of fractions", 98 | " ```", 99 | " or `other`.", 100 | "", 101 | " If you have added a new submodule, please add it to the list of valid release notes headers at https://github.com/sympy/sympy-bot/blob/master/sympy_bot/submodules.txt.", 102 | ] 103 | 104 | else: 105 | changelogs[header] # initialize the defaultdict 106 | 107 | else: 108 | if not header: 109 | message_list += [ 110 | '* No subheader found. Please add a header for the module, for example,', 111 | '', 112 | ' ```', 113 | ' * solvers', 114 | ' * improve solving of trig equations', 115 | ' ```', 116 | '', 117 | " A list of valid headers can be found at https://github.com/sympy/sympy-bot/blob/master/sympy_bot/submodules.txt.", 118 | ] 119 | status = False 120 | break 121 | elif changelogs[header] and (not line or line.startswith(prefix)) and not is_bullet(line): 122 | # Multiline changelog 123 | len_line_prefix = len(line) - len(line.lstrip()) 124 | changelogs[header][-1] += '\n' + ' '*(len_line_prefix - len(prefix)) + line.lstrip() 125 | elif line.strip() and not is_bullet(line): 126 | message_list += [ 127 | f'* The line `{line}` does not appear to have a valid Markdown bullet. Make sure it starts with `* `, `- `, or `+ ` with a space after the bullet.', 128 | ] 129 | status = False 130 | break 131 | elif line.strip() and len(line) - len(line.lstrip()) < 2: 132 | message_list += [ 133 | f"* The line `{line}` is not indented far enough. Please add at least two spaces before indented bullet points." 134 | ] 135 | status = False 136 | break 137 | else: 138 | prefix = ' '*(len(line) - len(line.lstrip())) 139 | changelogs[header].append(line.strip()) 140 | else: 141 | if not changelogs: 142 | message_list += [f'* No release notes were found. Please edit the PR description and write the release notes under `{BEGIN_RELEASE_NOTES}`.'] 143 | status = False 144 | 145 | for header in changelogs: 146 | # Clear "empty" changes 147 | changes = [] 148 | for change in changelogs[header]: 149 | if change.strip(): 150 | changes.append(change.strip()) 151 | changelogs[header] = changes 152 | if not changelogs[header]: 153 | message_list += [ 154 | '* Invalid release notes entry for `%s`. Make sure it has a release notes entry under it.' % header, 155 | ] 156 | status = False 157 | if not message_list: 158 | if not changelogs: 159 | status = False 160 | message_list = [f'* No release notes were found. Please edit the PR description and write the release notes under `{BEGIN_RELEASE_NOTES}`.'] 161 | else: 162 | message_list = ["Your release notes are in good order."] 163 | if not status: 164 | changelogs.clear() 165 | return status, '\n'.join(message_list), changelogs 166 | 167 | def get_release_notes_filename(version): 168 | """ 169 | Return filename of release notes for current development version 170 | """ 171 | v = VERSION_RE.match(version).group() 172 | return 'Release-Notes-for-' + v + '.md' 173 | 174 | def format_authors(authors): 175 | authors = sorted(authors, key=str.lower) 176 | 177 | if len(authors) == 1: 178 | authors_info = AUTHOR.format(author=authors[0]) 179 | elif len(authors) == 2: 180 | authors_info = AUTHOR.format(author=authors[0]) + " and " + AUTHOR.format(author=authors[1]) 181 | else: 182 | authors_info = ", ".join([AUTHOR.format(author=author) for author 183 | in authors[:-1]]) + ', and ' + AUTHOR.format(author=authors[-1]) 184 | 185 | return authors_info 186 | 187 | def format_change(change, pr_number, authors): 188 | authors_info = format_authors(authors) 189 | 190 | indent = ' ' 191 | if '\n\n' in change or ("```" in change and '\n' in change): 192 | change += '\n\n' 193 | indent = ' ' 194 | 195 | return textwrap.indent(change + SUFFIX.format(indent=indent, 196 | pr_number=pr_number, authors=authors_info), ' '*len(PREFIX)) 197 | 198 | def update_release_notes(*, rel_notes_txt, changelogs, pr_number, authors): 199 | """ 200 | Update release notes 201 | 202 | changelogs is assumed to only contain valid headers 203 | """ 204 | new_txt = [] 205 | 206 | valid_headers = get_valid_headers() 207 | changelogs = changelogs.copy() 208 | 209 | lines = iter(rel_notes_txt.splitlines()) 210 | for line in lines: 211 | new_txt.append(line) 212 | if line == "## Changes": 213 | break 214 | else: 215 | raise RuntimeError("The `## Changes` header was not found in the release notes file.") 216 | 217 | for line in lines: 218 | new_txt.append(line) 219 | line_header = line.lstrip(PREFIX) 220 | if line.startswith(PREFIX): 221 | if line_header not in valid_headers: 222 | continue 223 | for header in sorted(changelogs, key=valid_headers.index): 224 | if (valid_headers.index(header) < 225 | valid_headers.index(line_header)): 226 | # We reached a header after one we haven't processed, 227 | # meaning it isn't in the file, so add it. 228 | del new_txt[-1] 229 | new_txt.append(PREFIX + header) 230 | for change in changelogs[header]: 231 | new_txt.append(format_change(change, pr_number, authors)) 232 | del changelogs[header] 233 | new_txt.append(line) 234 | continue 235 | 236 | if line_header in changelogs: 237 | for change in changelogs[line_header]: 238 | new_txt.append(format_change(change, pr_number, authors)) 239 | del changelogs[line_header] 240 | continue 241 | 242 | if line == "## Authors": 243 | del new_txt[-1] 244 | # Keep the order from submodules.txt 245 | for header in valid_headers: 246 | if header in changelogs: 247 | new_txt.append(PREFIX + header) 248 | for change in changelogs[header]: 249 | new_txt.append(format_change(change, pr_number, authors)) 250 | new_txt.append(line) 251 | changelogs.clear() 252 | 253 | new_txt.append('') 254 | if changelogs: 255 | raise RuntimeError("Not all changelog entries were added. Make sure there is a header called `## Authors` at the end of the release notes.") 256 | 257 | return '\n'.join(new_txt) 258 | -------------------------------------------------------------------------------- /sympy_bot/submodules.txt: -------------------------------------------------------------------------------- 1 | # This is a list of SymPy submodules, which are valid headers for the release 2 | # notes. New top-level modules or major submodules should be added here. Keep 3 | # the entries in alphabetical order, as the order here is the order used in 4 | # the release notes. Each line should have one valid header. Blank lines and 5 | # lines that that start with a # are ignored. 6 | 7 | abc 8 | algebras 9 | assumptions 10 | benchmarks 11 | calculus 12 | categories 13 | codegen 14 | combinatorics 15 | concrete 16 | core 17 | crypto 18 | diffgeom 19 | discrete 20 | external 21 | functions 22 | geometry 23 | holonomic 24 | integrals 25 | interactive 26 | liealgebras 27 | logic 28 | matrices 29 | ntheory 30 | parsing 31 | 32 | # physics is disparate enough to list each submodule separately 33 | physics.biomechanics 34 | physics.continuum_mechanics 35 | physics.control 36 | physics.gaussopt 37 | physics.hep 38 | physics.hydrogen 39 | physics.matrices 40 | physics.mechanics 41 | physics.optics 42 | physics.paulialgebra 43 | physics.pring 44 | physics.qho_1d 45 | physics.quantum 46 | physics.secondquant 47 | physics.sho 48 | physics.units 49 | physics.vector 50 | physics.wigner 51 | 52 | plotting 53 | polys 54 | printing 55 | sandbox 56 | series 57 | sets 58 | simplify 59 | solvers 60 | stats 61 | strategies 62 | tensor 63 | testing 64 | unify 65 | utilities 66 | vector 67 | 68 | # other is a special header for any changes that don't fit into a submodule 69 | other 70 | -------------------------------------------------------------------------------- /sympy_bot/tests/Release-Notes-for-1.2.md: -------------------------------------------------------------------------------- 1 | These are the release notes for SymPy 1.2. 2 | 3 | SymPy 1.2 was released on Jul 9, 2018. 4 | 5 | This version of SymPy has been tested on Python 2.7, 3.4, 3.5, 3.6, 3.7 and 6 | PyPy. See our [Python version support 7 | policy](https://github.com/sympy/sympy/wiki/Python-version-support-policy) for 8 | more information on when we plan to drop support for older Python versions. 9 | 10 | Install SymPy with 11 | 12 | pip install -U sympy 13 | 14 | or if you use Anaconda 15 | 16 | conda install sympy 17 | 18 | ## Highlights 19 | 20 | There are many changes in 1.2 (see below). Some of the highlights are 21 | 22 | * Python 3.3 is no longer supported. If you require Python 3.3 support, use 23 | SymPy 1.1.1. See our 24 | [policy](https://github.com/sympy/sympy/wiki/Python-version-support-policy) 25 | on dropping support for major Python versions. 26 | 27 | * Experimental LaTeX parsing with `sympy.parsing.latex.parse_latex()` has been 28 | added, based on the `latex2sympy` project. This requires 29 | `antlr-python-runtime` to be installed. 30 | [#13706](https://github.com/sympy/sympy/pull/13706) 31 | 32 | * The vector module has been improved to support orthogonal curvilinear 33 | coordinate systems ([Szymon Mieszczak's GSoC 34 | project](https://github.com/sympy/sympy/wiki/Szymon-Mieszczak,-GSoC-2017-Report,-Implementation-of-multiple-types-of-coordinate-systems-for-vectors)) 35 | 36 | * New module `sympy.integrals.intpoly` for integrating uni/bivariate polynomials 37 | over 2-polytopes. ([Arif Ahmed's GSoC 38 | project](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Arif-Ahmed-:-Integration-over-Polytopes)) 39 | 40 | * Improvements to the code generation module. ([Björn Dahlgren's GSoC 41 | project](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Bj%C3%B6rn-Dahlgren:-Improved-code-generation-facilities)) 42 | 43 | * Improvements to the group theory module. See below for more information. 44 | ([Valeriia Gladkova's GSoC 45 | project](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Valeriia-Gladkova:-Group-Theory)) 46 | 47 | * New module `sympy.discrete` for operating on discrete sequences. ([Sidhant Nagpal's GSoC 48 | project](https://github.com/sidhantnagpal/gsoc/wiki/GSoC-2018-Application-Sidhant-Nagpal:-Transforms,-Convolution-&-Linear-Recurrence-Evaluation)) 49 | 50 | Other GSoC projects, [Abdullah Javed 51 | Nesar](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Abdullah-Javed-Nesar:-Rule-based-Integrator), 52 | [Arif 53 | Ahmed](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Arif-Ahmed-:-Integration-over-Polytopes), 54 | and [Gaurav Dhingra](https://github.com/gxyd/GSoC-2017-Report) are work in 55 | progress improvements to the `integrate` module (particularly, the new `rubi` 56 | submodule and the `risch` submodule), which are not yet visible to user-facing 57 | code. 58 | 59 | ## Backwards compatibility breaks and deprecations 60 | 61 | * Units: the arguments `dimension` and `scale_factor` in `Quantity` have been 62 | deprecated (see [#14319](https://github.com/sympy/sympy/issues/14319)). 63 | 64 | * Units: Dimensional dependencies are now part of DimensionSystem. All related 65 | methods inside Dimension have been deprecated in favor of the methods in 66 | DimensionSystem (see [#13336](https://github.com/sympy/sympy/issues/13336)). 67 | 68 | * `Matrix.dot` now only works for row and column matrices. Use `*` or `@` to 69 | do matrix multiplication. (see 70 | [#13815](https://github.com/sympy/sympy/issues/13815)). 71 | 72 | * The `k` argument for `line3D.equation()` is deprecated (see 73 | [#13742](https://github.com/sympy/sympy/issues/13742)). 74 | 75 | * `lambdify` no longer automatically wraps arguments with `np.array`, which 76 | improves the performance of calls to lambdified functions. Array wrapping is 77 | necessary if the input involves an operation that would raise an exception 78 | on scalar input, such `lambdify(x, 1/x)(0)`, but these cases are relatively 79 | rare, and wrapping can always be done by the user if necessary. 80 | [#14823](https://github.com/sympy/sympy/pull/14823) 81 | 82 | ## Changes 83 | 84 | * algebras 85 | * add new algebras submodule 86 | * add Quaternion class [#13285](https://github.com/sympy/sympy/pull/13285) 87 | 88 | * calculus 89 | * periodicity: added support for modulo operation 90 | [#13226](https://github.com/sympy/sympy/pull/13226) 91 | * fixed continuous_domain to remove singularities. 92 | [#13335](https://github.com/sympy/sympy/pull/13335) 93 | * Finite Set domains for `function_range` 94 | [#13479](https://github.com/sympy/sympy/pull/13479) 95 | * Fix `function_range` for periodic functions 96 | [#13512](https://github.com/sympy/sympy/pull/13512) 97 | 98 | * combinatorics 99 | * correct gray_to_bin and bin_to_gray 100 | [#14468](https://github.com/sympy/sympy/pull/14468) 101 | * improve the comparison test for convergence 102 | [#14509](https://github.com/sympy/sympy/pull/14509) 103 | 104 | * concrete 105 | * Improve summation of geometric series 106 | [#13991](https://github.com/sympy/sympy/pull/13991) 107 | * add the ratio test to Sum.is_convergent 108 | [#14158](https://github.com/sympy/sympy/pull/14158) 109 | * add the limit comparison test for is_convergent 110 | [#14401](https://github.com/sympy/sympy/pull/14401) 111 | * add bounded times summation convergence test 112 | [#14464](https://github.com/sympy/sympy/pull/14464) 113 | * add factoring to compute certain sums with negative exponents 114 | [#14641](https://github.com/sympy/sympy/pull/14641) 115 | 116 | * core 117 | * Derivatives by a variable a symbolic number of times, like `diff(f(x), (x, 118 | n))` to represent `d^nf/dx^n`, where `n` can be symbolic. 119 | [#13751](https://github.com/sympy/sympy/pull/13751) 120 | [#13892](https://github.com/sympy/sympy/pull/13892) 121 | * various improvements to `Piecewise` and `piecewise_fold`. 122 | [#12951](https://github.com/sympy/sympy/pull/12951), 123 | [#12920](https://github.com/sympy/sympy/pull/12920), 124 | [#13074](https://github.com/sympy/sympy/pull/13074), 125 | [#13282](https://github.com/sympy/sympy/pull/13282), 126 | [#13309](https://github.com/sympy/sympy/pull/13309), 127 | [#13204](https://github.com/sympy/sympy/pull/13204), 128 | [#12587](https://github.com/sympy/sympy/pull/12587), 129 | [#14071](https://github.com/sympy/sympy/pull/14071), 130 | [#14682](https://github.com/sympy/sympy/pull/14682) 131 | * Make Pow.subs not use fractional powers for noncommutative objects 132 | [#13018](https://github.com/sympy/sympy/pull/13018) 133 | * nullary undefined functions (like `f = Function('f'); f()`) are now 134 | allowed [#12977](https://github.com/sympy/sympy/pull/12977) 135 | * add a global option and context manager to disable automatic distribution 136 | (`2*(x + y)` -> `2*x + 2*y`) 137 | [#12440](https://github.com/sympy/sympy/pull/12440) 138 | * make Mul.flatten aware of MatrixExpr 139 | [#13279](https://github.com/sympy/sympy/pull/13279) 140 | * Fix `__rfloordiv__` not being reversed 141 | [#13368](https://github.com/sympy/sympy/pull/13368) 142 | * `Expr` now supports the 3-argument version of the builtin `pow` 143 | [#13364](https://github.com/sympy/sympy/pull/13364) 144 | * Allow undefined functions to be defined with assumptions (like 145 | `Function('f', real=True)`). 146 | [#12945](https://github.com/sympy/sympy/pull/12945) 147 | * Allow Floats to be created from NumPy floats 148 | [#13426](https://github.com/sympy/sympy/pull/13426) 149 | * Fix some incorrect comparisons between numbers 150 | [#13429](https://github.com/sympy/sympy/pull/13429) 151 | * removed x**1.0 == x hack from the core (they are now unequal, remember 152 | that `==` in SymPy means symbolic, not mathematical equality) 153 | [#13518](https://github.com/sympy/sympy/pull/13518) 154 | * Subs substitution now supports derivative by indexed objects in various 155 | cases [#13452](https://github.com/sympy/sympy/pull/13452) 156 | * Add more simplifications for Mod 157 | [#13581](https://github.com/sympy/sympy/pull/13581) 158 | * make `f(1).is_number` give False, where `f = Function('f')` 159 | [#13619](https://github.com/sympy/sympy/pull/13619) 160 | * allow differentiating with respect to tuples, returning an Array type 161 | [#13655](https://github.com/sympy/sympy/pull/13655) 162 | * correct sympify for numpy arrays of size 1 163 | [#13926](https://github.com/sympy/sympy/pull/13926) 164 | * Do not over-simplify rational powers of negative integers 165 | [#13895](https://github.com/sympy/sympy/pull/13895) 166 | * Fix the negative rational powers of negative integers and rationals 167 | [#14024](https://github.com/sympy/sympy/pull/14024) 168 | * improve the performance of modular exponentiation for very large powers 169 | [#14249](https://github.com/sympy/sympy/pull/14249) 170 | * improve mod_inverse for negative numbers 171 | [#14333](https://github.com/sympy/sympy/pull/14333) 172 | * prevent infinite recursion in as_real_imag 173 | [#14404](https://github.com/sympy/sympy/pull/14404) 174 | * remove automatic distribution of infinite factors 175 | [#14383](https://github.com/sympy/sympy/pull/14383) 176 | * fix floor and ceiling of infinite quantities 177 | [#14328](https://github.com/sympy/sympy/pull/14328) 178 | * fix evaluation of negative terms with sympify(evaluate=False) 179 | [#11095](https://github.com/sympy/sympy/issues/11095) 180 | * SymPy numeric expressions now work with `math.trunc` 181 | [#14451](https://github.com/sympy/sympy/pull/14451) 182 | * fix `Mod(x**2, x)` giving 0 (it is not 0 if `x` is fractional) 183 | [#13177](https://github.com/sympy/sympy/pull/13177) 184 | * fix evalf with subs giving incorrect results with `floor` 185 | [#13361](https://github.com/sympy/sympy/pull/13361) 186 | * prevent substitutions in Derivative in cases where it changes the 187 | mathematical meaning of the expression 188 | [#13803](https://github.com/sympy/sympy/pull/13803) 189 | * allow complex powers to be rewritten as exp 190 | [#14712](https://github.com/sympy/sympy/pull/14712) 191 | 192 | * codegen 193 | * Improvements to the code generation module. ([Björn Dahlgren's GSoC 194 | project](https://github.com/sympy/sympy/wiki/GSoC-2017-Report-Bj%C3%B6rn-Dahlgren:-Improved-code-generation-facilities)), including: 195 | * many additions to the sympy.codegen.ast module 196 | * new sympy.codegen rewriting module with codegen related expression 197 | optimizations 198 | * new sympy.codegen.algorithms module with code generatable algorithms using 199 | codegen.ast 200 | * new sympy.codegen.approximations module with code generatable approximations 201 | * add a code printer for GLSL (`glsl_code`) 202 | [#12713](https://github.com/sympy/sympy/pull/12713) 203 | * add support for tensorflow 1.0+ 204 | [#13413](https://github.com/sympy/sympy/pull/13413) 205 | * added printer flag so that printer can be passed to codegen() 206 | [#13587](https://github.com/sympy/sympy/pull/13587) 207 | * Add support for fmod in ccode 208 | [#13692](https://github.com/sympy/sympy/pull/13692) 209 | * add more functions to rcode 210 | [#13840](https://github.com/sympy/sympy/pull/13840) 211 | * add more functions to jscode 212 | [#13832](https://github.com/sympy/sympy/pull/13832) 213 | * add support for Max and Min in fcode and octave 214 | [#13903](https://github.com/sympy/sympy/pull/13903) 215 | * fix `pycode()` and 'lambdify' for `Piecewise` functions 216 | [#13969](https://github.com/sympy/sympy/pull/13969) 217 | * fix imaginary number printing in octave 218 | [#13978](https://github.com/sympy/sympy/pull/13978) 219 | * add re, im, arg functions to octave codegen 220 | [#14013](https://github.com/sympy/sympy/pull/14013) 221 | * Fix printing of Fortran code with variables that are the same when 222 | compared case insensitively 223 | [#14003](https://github.com/sympy/sympy/pull/14003) 224 | * Add support for sympy.sign in .printing.pycode 225 | [#14010](https://github.com/sympy/sympy/pull/14010) 226 | * support zoo and -oo in lambdify 227 | [#14306](https://github.com/sympy/sympy/pull/14306) 228 | * lambdify: Improved speed of generated code when unpacking arguments 229 | [#14691](https://github.com/sympy/sympy/pull/14691) 230 | * lambdify now generates actual Python functions rather than lambdas 231 | [#14713](https://github.com/sympy/sympy/pull/14713) 232 | * the `dummify` argument to `lambdify` now by default only converts the 233 | arguments to Dummy when necessary 234 | [#14713](https://github.com/sympy/sympy/pull/14713) 235 | * Add lambdified function source to the linecache. This makes 236 | `inspect.getsource`, IPython's `??`, IPython tracebacks, and most 237 | debuggers show the source code for lambdified functions 238 | [#14739](https://github.com/sympy/sympy/pull/14739) 239 | 240 | * crypto 241 | * Add Goldwasser Micali Encryption 242 | [#13666](https://github.com/sympy/sympy/pull/13666) 243 | 244 | * discrete 245 | * new submodule `sympy.discrete` for operating on discrete sequences with 246 | the following functions: 247 | * `fft`, `ifft` (fast discrete Fourier transforms), `ntt`, `intt` (number theoretic 248 | transform) [#14725](https://github.com/sympy/sympy/pull/14725) 249 | * `convolution` (convolutions on discrete sequences) 250 | [#14745](https://github.com/sympy/sympy/pull/14745), [#14783](https://github.com/sympy/sympy/pull/14783) 251 | * `fwht` and `ifwht` (fast Walsh-Hadamard transform) 252 | [#14765](https://github.com/sympy/sympy/pull/14765) 253 | * Added the ability to evaluate linear recurrences without obtaining 254 | closed form expressions 255 | [#14816](https://github.com/sympy/sympy/pull/14816) 256 | 257 | * functions 258 | * Improve trigonometric function evaluation and logarithmic rewrite 259 | [#13109](https://github.com/sympy/sympy/pull/13109) 260 | * `euler` can now compute Euler polynomials 261 | [#13228](https://github.com/sympy/sympy/pull/13228) 262 | * add some rewrite methods for `sinc` 263 | [#11870](https://github.com/sympy/sympy/pull/11870) 264 | * use gmpy to compute `binomial` if it is installed 265 | [#13394](https://github.com/sympy/sympy/pull/13394) 266 | * fix for B-Splines of higher than 1 degree with repeated knots 267 | [#12214](https://github.com/sympy/sympy/pull/12214) 268 | * allow Min and Max to be rewritten in terms of Abs 269 | [#13614](https://github.com/sympy/sympy/pull/13614) 270 | * add some assumptions for Catalan numbers 271 | [#13628](https://github.com/sympy/sympy/pull/13628) 272 | * Added an interpolating_spline function for symbolic interpolation of 273 | splines [#13829](https://github.com/sympy/sympy/pull/13829) 274 | * Implement more special values of polylogarithm, without exp_polar for s=1 275 | [#13852](https://github.com/sympy/sympy/pull/13852) 276 | * Improved legendre polynomial for negative n 277 | [#13920](https://github.com/sympy/sympy/pull/13920) 278 | * Add evaluation of polygamma(0, z) for all rational z 279 | [#14045](https://github.com/sympy/sympy/pull/14045) 280 | * More general unpolarification for polylog argument 281 | [#14076](https://github.com/sympy/sympy/pull/14076) 282 | * fix `floor` sometimes giving the wrong answer 283 | [#14167](https://github.com/sympy/sympy/pull/14167) 284 | * return zeta(2*n) and zeta(-n) as expressions 285 | [#14178](https://github.com/sympy/sympy/pull/14178) 286 | * automatic log expansion of numbers removed 287 | [#14134](https://github.com/sympy/sympy/pull/14134) 288 | * improve absolute value of complex numbers raised to the power of complex 289 | numbers [#14278](https://github.com/sympy/sympy/pull/14278) 290 | * improve evaluation of inverse trig functions 291 | [#14321](https://github.com/sympy/sympy/pull/14321) 292 | * allow exp to be rewritten as sqrt 293 | [#14358](https://github.com/sympy/sympy/pull/14358) 294 | * evaluate transcendental functions at complex infinity 295 | [#14406](https://github.com/sympy/sympy/pull/14406) 296 | * improve automatic evaluation of reciprocal trig functions 297 | [#14544](https://github.com/sympy/sympy/pull/14544) 298 | * Support added for computing binomial(n, k) where k can be noninteger and n 299 | can be any number. [#14019](https://github.com/sympy/sympy/pull/14019) by 300 | [Yathartha Joshi](https://github.com/Yathartha22) 301 | * Fix binomial(n, k) for negative integer k 302 | [#14575](https://github.com/sympy/sympy/pull/14575) 303 | * Optimize binomial evaluation for integers 304 | [#14576](https://github.com/sympy/sympy/pull/14576) 305 | * implemented partition numbers (`partition()`) 306 | [#14617](https://github.com/sympy/sympy/pull/14617) 307 | * automatically reduce nested floor and ceiling expressions 308 | [#14631](https://github.com/sympy/sympy/pull/14631) 309 | * improve performance Mod of factorial and binomial expressions 310 | [#14636](https://github.com/sympy/sympy/pull/14636) 311 | * improve floor and ceiling rewriting and equality testing 312 | [#14647](https://github.com/sympy/sympy/pull/14647) 313 | 314 | * geometry: 315 | * Second moment and product moment of area of a two-dimensional polygon can 316 | now be computed. [#13939](https://github.com/sympy/sympy/pull/13939) by 317 | [Keshri Kumar Rushyam](https://github.com/rushyam). 318 | * Second moment and product moment of area of an ellipse and a circle can 319 | now be computed. [#14190](https://github.com/sympy/sympy/pull/14190) by 320 | [Keshri Kumar Rushyam](https://github.com/rushyam). 321 | * pairwise intersections. 322 | [#12963](https://github.com/sympy/sympy/pull/12963) 323 | * Added `closing_angle` which represents the angle of one linear entity 324 | relative to another. [#13002](https://github.com/sympy/sympy/pull/13002) 325 | * Added method to compute the exradius of a triangle 326 | [#13318](https://github.com/sympy/sympy/pull/13318) 327 | * Implemented a length property for the Curve class. 328 | [#13328](https://github.com/sympy/sympy/pull/13328) 329 | * make arbitrary_point of Plane run through all points 330 | [#13807](https://github.com/sympy/sympy/pull/13807) 331 | * Remove integral from ellipse circumference calculation 332 | [#14435](https://github.com/sympy/sympy/pull/14435) 333 | 334 | * groups 335 | * It is now possible to check if a finitely presented group is infinite with 336 | `_is_infinite` method (this may return `None` in a number of cases where 337 | the algorithm cannot establish whether a group is infinite). The `order` 338 | method now returns `S.Infinity` for some infinite groups. 339 | ([#12705](https://github.com/sympy/sympy/pull/12705)) 340 | * `subgroup` method was added for permutation and finitely presented groups 341 | to return the subgroup generated by given group elements 342 | ([#12658](https://github.com/sympy/sympy/pull/12658)). Subgroups of 343 | finitely presented groups end up having different generators from the 344 | original group but a special class `FpSubgroup` can be used as a 345 | substitute if having the same generators is important. 346 | ([#12827](https://github.com/sympy/sympy/pull/12827)) 347 | * The presentation of a finitely presented group can be simplified with 348 | `simplify_presentation` 349 | * Group homomorphisms have been implemented as a `GroupHomomorphism` class 350 | in `sympy.combinatorics.homomorphisms`. A homomorphism can be created 351 | using the function `homomorphism` and additionally one can create special 352 | types of group action homomorphisms for permutation groups with 353 | `block_homomorphism` and `orbit_homomorphism`. 354 | ([#12827](https://github.com/sympy/sympy/pull/12827) and 355 | [#13104](https://github.com/sympy/sympy/pull/13104)) 356 | * Rewriting systems for finitely presented groups are implemented. If a 357 | confluent rewriting system is found, this allows to reduce elements to 358 | their normal forms. Otherwise, at least the found reduction rules cab be 359 | used to equate some group elements that otherwise would be treated as 360 | different previously. See 361 | [#12893](https://github.com/sympy/sympy/pull/12893) for more information. 362 | * Convert between permutation and finite presentation of groups by using 363 | `presentation` and `strong_presentation` method of permutation groups 364 | ([#12986](https://github.com/sympy/sympy/pull/12986) and 365 | [#13698](https://github.com/sympy/sympy/pull/13698)) and 366 | `_to_perm_group()` for finitely presented groups 367 | ([#13119](https://github.com/sympy/sympy/pull/13119)) 368 | * Compute Sylow subgroups of permutation groups with `sylow_subgroup` 369 | method. ([#13104](https://github.com/sympy/sympy/pull/13104)) 370 | * A number of methods and attributes previously only available for 371 | permutation groups now can be used with finite finitely presented groups. 372 | These include `derived_series`, `lower_central_series`, `center`, 373 | `derived_subgroup`, `centralizer`, `normal_closure`, `is_abelian`, 374 | `is_nilpotent`, `is_solvable` and `elements`. 375 | ([#13119](https://github.com/sympy/sympy/pull/13119)) 376 | 377 | * holonomic 378 | * Generalize Holonomic to work on symbolic powers 379 | [#12989](https://github.com/sympy/sympy/pull/12989) 380 | 381 | * integrals 382 | * Rewrite Abs and sign as Piecewise for 383 | integration[#13930](https://github.com/sympy/sympy/pull/13930) 384 | * Integrate Max and Min by rewriting them as Piecewise 385 | [#13919](https://github.com/sympy/sympy/pull/13919) 386 | * Add new trigonometric rule in manual integration 387 | [#12651](https://github.com/sympy/sympy/pull/12651) 388 | * Reorder Piecewise output of integration, so that generic answer is first 389 | [#13998](https://github.com/sympy/sympy/pull/13998) 390 | * Add symbolic Riemann sums for definite integrals 391 | [#13988](https://github.com/sympy/sympy/pull/13988) 392 | * In manualintegrate, when integrating by parts, do not use u = non-poly 393 | algebraic [#14015](https://github.com/sympy/sympy/pull/14015) 394 | * fix wrong result integration of rational functions in some cases when 395 | variables have assumptions 396 | [#14082](https://github.com/sympy/sympy/pull/14082) 397 | * simplify some convergence conditions for integrals 398 | [#14092](https://github.com/sympy/sympy/pull/14092) 399 | * add a manualintegrate rule for expanding trig functions 400 | [#14408](https://github.com/sympy/sympy/pull/14408) 401 | * improve manualintegrate for substitutions `u=(a*x+b)**(1/n)` 402 | [#14480](https://github.com/sympy/sympy/pull/14480) 403 | * better handling of manual, meijerg, risch flags in integrate 404 | [#14475](https://github.com/sympy/sympy/pull/14475) 405 | * add `floor` to certain discontinuous integrals 406 | [#13808](https://github.com/sympy/sympy/pull/13808) 407 | * add integration of orthogonal polynomials of general degree 408 | [#14521](https://github.com/sympy/sympy/pull/14521) 409 | * allow integrals with inequalities, such as Integral(x, 410 | x>2) [#14586](https://github.com/sympy/sympy/pull/14586) 411 | 412 | * interpolation 413 | * A spline of given degree that interpolates given data points can be 414 | constructed with `interpolating_spline`. 415 | [#13829](https://github.com/sympy/sympy/pull/13829) by 416 | [Jashan](https://github.com/jashan498). 417 | 418 | * liealgebras 419 | * fix instantiate of CartanType with a string like CartanType('A34') 420 | [#14568](https://github.com/sympy/sympy/pull/14568) 421 | 422 | * matrices 423 | * Computing the Smith Normal Form of a matrix over a ring is now implemented 424 | as the function `smith_normal_form` from `sympy.matrices.normalforms` 425 | ([#12705](https://github.com/sympy/sympy/pull/12705)) 426 | * Derivatives for expressions of matrix symbols now experimentally 427 | supported. 428 | * Matrices can be used as deriving variable. 429 | * Matrix norm for order 1 has been added 430 | [#12616](https://github.com/sympy/sympy/pull/12616) 431 | * add MatrixExpr.from_index_summation, which parses expression of matrices 432 | with explicitly summed indices into a matrix expression without indices, 433 | if possible [#13542](https://github.com/sympy/sympy/pull/13542) 434 | * Add functionality for infinity norm of matrices 435 | [#13986](https://github.com/sympy/sympy/pull/13986) 436 | * add numeric check for pivot being zero in Bareiss determinant method 437 | [#13877](https://github.com/sympy/sympy/pull/13877) 438 | * Implement Kronecker product of Matrices 439 | [#14264](https://github.com/sympy/sympy/pull/14264) 440 | * give a simpler representation of exp(M) when M is real 441 | [#14331](https://github.com/sympy/sympy/pull/14331) 442 | * avoid loss of precision in jordan_form 443 | [#13982](https://github.com/sympy/sympy/pull/13982) 444 | * made Mod elementwise for matrices 445 | [#14498](https://github.com/sympy/sympy/pull/14498) 446 | * implement Cholesky and LDL decompositions for Hermitian matrices 447 | [#14474](https://github.com/sympy/sympy/pull/14474) 448 | 449 | * ntheory 450 | * Added totient and mobius range methods to Sieve 451 | [#14628](https://github.com/sympy/sympy/pull/14628) 452 | 453 | * physics.continuum_mechanics 454 | * Added applied_loads and remove_load methods 455 | [#14751](https://github.com/sympy/sympy/pull/14751) 456 | * added method to find point of contraflexure 457 | [#14753](https://github.com/sympy/sympy/pull/14753) 458 | * Added support for composite beams 459 | [#14773](https://github.com/sympy/sympy/pull/14773) 460 | * added `apply_support` and `max_deflection` methods 461 | [#14786](https://github.com/sympy/sympy/pull/14786) 462 | 463 | * physics.mechanics 464 | * add gravity function [#14171](https://github.com/sympy/sympy/pull/14171) 465 | 466 | * physics.optics 467 | * Added transverse_magnification 468 | [#10625](https://github.com/sympy/sympy/pull/10625) 469 | * Implemented Brewster's Angle in Optics Module 470 | [#13756](https://github.com/sympy/sympy/pull/13756) 471 | 472 | * physics.quantum 473 | * Adding support for simplifying powers of tensorproducts 474 | [#13974](https://github.com/sympy/sympy/pull/13974) 475 | 476 | * physics.vector 477 | * fix error pretty printing physics vectors 478 | [#14717](https://github.com/sympy/sympy/pull/14717) 479 | 480 | * physics.wigner 481 | * Make wigner_9j work for certain half-integer argument combinations 482 | [#13602](https://github.com/sympy/sympy/pull/13602) 483 | 484 | * plotting 485 | * Fix plotting of constant functions 486 | [#14023](https://github.com/sympy/sympy/pull/14023) 487 | 488 | * polys 489 | * `degree` now requires a generator to be specified for multivariate 490 | polynomials (use `total_degree` for the total degree). 491 | [#13173](https://github.com/sympy/sympy/pull/13173) 492 | * `total_degree` function added 493 | [#13277](https://github.com/sympy/sympy/pull/13277) 494 | * Allow `itermonomials` to respect non-commutative variables 495 | [#13327](https://github.com/sympy/sympy/pull/13327) 496 | * Improve srepr of polynomial rings and fraction fields 497 | [#13345](https://github.com/sympy/sympy/pull/13345) 498 | * add `Poly.norm`, which computes the conjugates of a polynomial over a 499 | number field. [#13304](https://github.com/sympy/sympy/pull/13304) 500 | * add `srepr` printer for `DMP` 501 | [#13367](https://github.com/sympy/sympy/pull/13367) 502 | * added Finite ring extensions to the `agca` submodule 503 | [#13378](https://github.com/sympy/sympy/pull/13378) 504 | * fix factor for expressions with floating point coefficients 505 | [#13198](https://github.com/sympy/sympy/pull/13198) 506 | * Changed ModularInteger to use fast exponentiation 507 | [#14093](https://github.com/sympy/sympy/pull/14093) 508 | * fix minpoly(I) over domains that contain I 509 | [#14382](https://github.com/sympy/sympy/pull/14382) 510 | * fix the quartic root solver for certain quartic polynomials with complex 511 | coefficients [#14522](https://github.com/sympy/sympy/pull/14522) 512 | * implement Dixons and Macaulay multivariate resultants 513 | [#14370](https://github.com/sympy/sympy/pull/14370) 514 | * support gcd and lcm for compatible irrational numbers 515 | [#14365](https://github.com/sympy/sympy/pull/14365) 516 | * optimized Lagrange Interpolation 517 | [#14603](https://github.com/sympy/sympy/pull/14603) 518 | 519 | * printing 520 | * Latex printer now supports custom printing of logarithmic functions by 521 | newly by accepting `ln_notation` as a boolean (default=`False`) keyword 522 | argument. ([#14180](https://github.com/sympy/sympy/pull/14180)) 523 | * Add `mathml` and `cxxcode` to `from sympy import *`. 524 | [#12937](https://github.com/sympy/sympy/pull/12937) 525 | * Add `sympy_integers` option to the str printer to print Rationals in a 526 | SymPy recreatable way. [#13141](https://github.com/sympy/sympy/pull/13141) 527 | * change the set pretty printer to print with {} 528 | [#12087](https://github.com/sympy/sympy/pull/12087) 529 | * fix LaTeX dagger printing 530 | [#13672](https://github.com/sympy/sympy/pull/13672) 531 | * use a better symbol for Equivalent in the pretty printers 532 | [#14105](https://github.com/sympy/sympy/pull/14105) 533 | * fix pretty printing of DiracDelta 534 | [#14104](https://github.com/sympy/sympy/pull/14104) 535 | * change LaTeX printing of O() from `\mathcal{O}` to `\big{O}` 536 | [#14166](https://github.com/sympy/sympy/pull/14166) 537 | * print Poly terms in the correct order in the latex printer 538 | [#14317](https://github.com/sympy/sympy/pull/14317) 539 | * fix latex printing of SeqFormula 540 | [#13971](https://github.com/sympy/sympy/pull/13971) 541 | * add abbreviated printing for units 542 | [#13962](https://github.com/sympy/sympy/pull/13962) 543 | * allow user defined mul_symbol in latex() 544 | [#13798](https://github.com/sympy/sympy/pull/13798) 545 | * add a presentation MathML printer 546 | [#13794](https://github.com/sympy/sympy/pull/13794) 547 | * fix an issue with some missing parentheses from pprint and latex 548 | [#13673](https://github.com/sympy/sympy/pull/13673) 549 | * fix some instances where the str printer would not reuse the same printer 550 | class [#14531](https://github.com/sympy/sympy/pull/14531) 551 | * improve printing of expressions like `Pow(Mul(a,a,evaluate=False), -1, 552 | evaluate=False)` [#14207](https://github.com/sympy/sympy/pull/14207) 553 | * add conjugate to Mathematica printing 554 | [#14109](https://github.com/sympy/sympy/pull/14109) 555 | * the printers no longer dispatch undefined functions like `Function` 556 | subclasses (makes `Function('gamma')` print as `γ` instead of `Γ`) 557 | [#13822](https://github.com/sympy/sympy/pull/13822) 558 | * Make LaTeX printer print full trig names for acsc and asec 559 | [#14774](https://github.com/sympy/sympy/pull/14774) 560 | 561 | * series: 562 | * Bidirectional limits can now be computed by passing dir=+- argument to 563 | `limits.limit` function. 564 | [#11694](https://github.com/sympy/sympy/issue/11694) by [Gaurav 565 | Dhingra](https://github.com/gxyd). 566 | * Evaluate limits of sequences with alternating signs 567 | [#13976](https://github.com/sympy/sympy/pull/13976) 568 | 569 | * sets: 570 | * Ordinals can now be represented in Cantor normal form, and arithmetics can 571 | be performed which includes addition, multiplication, and basic 572 | exponentiation. [#13682](https://github.com/sympy/sympy/pull/13682) by 573 | [Ashish Kumar Gaurav](https://github.com/ashishkg0022) 574 | * SetExpr and arithmetic expressions of sets now supported (via 575 | multimethods). [#14106](https://github.com/sympy/sympy/pull/14106) 576 | * Singleton sets (Reals, Complexes, etc.) can now be accessed without `S` 577 | [#11383](https://github.com/sympy/sympy/pull/11383), 578 | [#12524](https://github.com/sympy/sympy/pull/12524), 579 | [#13572](https://github.com/sympy/sympy/pull/13572), 580 | [#14844](https://github.com/sympy/sympy/pull/14844) 581 | * Add set expressions [#2721](https://github.com/sympy/sympy/pull/2721), 582 | [#14301](https://github.com/sympy/sympy/pull/14301) 583 | * ImageSet now supports multiple sets 584 | [#14145](https://github.com/sympy/sympy/pull/14145) 585 | * be more careful in ConditionSet.subs 586 | [#14564](https://github.com/sympy/sympy/pull/14564) 587 | 588 | * simplify 589 | * improve simplification of Min and Max 590 | [#13054](https://github.com/sympy/sympy/pull/13054), 591 | [#13599](https://github.com/sympy/sympy/pull/13599) 592 | * New function `gammasimp` separated from `combsimp`. `combsimp` does not 593 | apply gamma function simplifications that may make an integer argument 594 | non-integer. Also various improvements to both. 595 | [#13210](https://github.com/sympy/sympy/pull/13210) 596 | * remove unused fu flag from `simplify`. 597 | [#13264](https://github.com/sympy/sympy/pull/13264) 598 | * add `rational` flag to `simplify`. 599 | [#13264](https://github.com/sympy/sympy/pull/13264) 600 | * improved simplification of hyperbolic functions 601 | [#13259](https://github.com/sympy/sympy/pull/13259) 602 | * improve the efficiency of `cse` 603 | [#13221](https://github.com/sympy/sympy/pull/13221) 604 | * Allow cse of unevaluated expressions 605 | [#13271](https://github.com/sympy/sympy/pull/13271) 606 | * fix logcombine for logs of numbers 607 | [#14070](https://github.com/sympy/sympy/pull/14070) 608 | * disable simplification of inverses in simplify() unless the new `inverse` 609 | flag is set [#14422](https://github.com/sympy/sympy/pull/14422) 610 | * `simplify()` now simplifies non-commutative expressions 611 | [#14520](https://github.com/sympy/sympy/pull/14520) 612 | 613 | * solvers 614 | * Enable initial condition solving in `dsolve` 615 | [#11264](https://github.com/sympy/sympy/pull/11264) 616 | * Added support for solving inequalities involving rational functions with 617 | imaginary coefficients [#13296](https://github.com/sympy/sympy/pull/13296) 618 | * the solvers now work with non-Symbols such as Indexed 619 | [#13415](https://github.com/sympy/sympy/pull/13415) 620 | * add failing_assumptions function 621 | [#12147](https://github.com/sympy/sympy/pull/12147) 622 | * Improve the solver for linear 1st order ODE systems of size 2 623 | [#13812](https://github.com/sympy/sympy/pull/13812) 624 | * fix classify_ode(dict=True) for Eq 625 | [#14663](https://github.com/sympy/sympy/pull/14663) 626 | * improve handling of infinite solutions in `solve` 627 | [#14749](https://github.com/sympy/sympy/pull/14749) 628 | 629 | * solveset 630 | * Solveset now supports exponential equations with negative base. 631 | [#13923](https://github.com/sympy/sympy/pull/13923) by [Yathartha 632 | Joshi](https://github.com/Yathartha22) 633 | * Fix `solve_univariate_inequality` for finite set domains 634 | [#13458](https://github.com/sympy/sympy/pull/13458) 635 | * solve more trig equations 636 | [#13941](https://github.com/sympy/sympy/pull/13941) 637 | * improve solveset with piecewise expressions 638 | [#14253](https://github.com/sympy/sympy/pull/14253) 639 | * Improved solveset for negative base exponential equations 640 | [#13844](https://github.com/sympy/sympy/pull/13844) 641 | * allow solving equations with Abs in solveset 642 | [#14449](https://github.com/sympy/sympy/pull/14449) 643 | 644 | * stats 645 | * Added pre-computed cumulative distribution functions to some Random 646 | Symbols [#13284](https://github.com/sympy/sympy/pull/13284), 647 | [#13804](https://github.com/sympy/sympy/pull/13804), 648 | [#13878](https://github.com/sympy/sympy/pull/13878) 649 | * add trapezoidal distribution 650 | [#13419](https://github.com/sympy/sympy/pull/13419) 651 | * The characteristic function of a probability distribution can be computed 652 | with `characteristic_function`. 653 | [#13851](https://github.com/sympy/sympy/pull/13851) and 654 | [#13958](https://github.com/sympy/sympy/pull/13958) by [Ethan 655 | Ward](https://github.com/ethankward). 656 | * Probability of continuous random variables lying in a finite set is now 657 | zero [#14254](https://github.com/sympy/sympy/pull/14254) 658 | * add sampling for GammaInverse distribution 659 | [#14250](https://github.com/sympy/sympy/pull/14250) 660 | * Allow Geometric RVs where `p` is a symbol 661 | [#14472](https://github.com/sympy/sympy/pull/14472) 662 | * various improvements to sampling methods 663 | * fix wrong results with Or conditions 664 | [#14578](https://github.com/sympy/sympy/pull/14578) 665 | * implemented probability for discrete random variables 666 | [#14119](https://github.com/sympy/sympy/pull/14119) 667 | * rename `restricted_domain` to `where` for discrete random variables 668 | [#14604](https://github.com/sympy/sympy/pull/14604) 669 | * implement several missing classes in discrete random variables 670 | [#14218](https://github.com/sympy/sympy/pull/14218) 671 | 672 | * tensor 673 | * do not require assumptions to be set on Idx bounds. 674 | [#12888](https://github.com/sympy/sympy/pull/12888) 675 | * improve `Indexed.free_symbols` 676 | [#13360](https://github.com/sympy/sympy/pull/13360) 677 | 678 | * units 679 | * add SI base units to sympy.physics.units.systems 680 | [#12897](https://github.com/sympy/sympy/pull/12897) 681 | * Implemented get_dimensional_expr for Derivative 682 | [#13003](https://github.com/sympy/sympy/pull/13003) 683 | * Implemented Quantity.get_dimensional_expr() for functions 684 | [#13011](https://github.com/sympy/sympy/pull/13011) 685 | * the relation between base dimensions and derived dimensions are now part 686 | of DimensionSystem, so that they are no longer absolute. 687 | [#13287](https://github.com/sympy/sympy/pull/13287) 688 | * Add Stefan Boltzmann constant 689 | [#13440](https://github.com/sympy/sympy/pull/13440) 690 | * Add Planck charge and derived Planck units 691 | [#13438](https://github.com/sympy/sympy/pull/13438) 692 | * Add katal, gray to derived units 693 | [#13658](https://github.com/sympy/sympy/pull/13658) 694 | * Added derived unit of Radioactivity 695 | [#13839](https://github.com/sympy/sympy/pull/13839) 696 | * fix subs for Quantities [#13855](https://github.com/sympy/sympy/pull/13855) 697 | * add simplification of quantities 698 | [#14286](https://github.com/sympy/sympy/pull/14286) 699 | 700 | * vector 701 | * fix vector + 0 [#14711](https://github.com/sympy/sympy/pull/14711) 702 | 703 | ## Minor changes 704 | 705 | * `free_symbols` and `find_dynamicsymbols` methods now available for vectors 706 | directly, if accompanied by a reference frame argument. 707 | [#13549](https://github.com/sympy/sympy/pull/13549) by [Saloni 708 | Jain](https://github.com/tosalonijain) 709 | 710 | * simplify `Relational.canonical` 711 | [#12906](https://github.com/sympy/sympy/pull/12906) 712 | 713 | * fix `(z**r).is_algebraic` where `z` is algebraic and `r` is rational 714 | [#12924](https://github.com/sympy/sympy/pull/12924)[<8;52;35m 715 | 716 | * Remove `register` keyword from C++17 standard in `cxxcode` 717 | [#12964](https://github.com/sympy/sympy/pull/12964) 718 | 719 | * Allow `autowrap`'s tempdir to be a relative path 720 | [#12944](https://github.com/sympy/sympy/pull/12944) 721 | 722 | * Fix symbols with superscripts raised to a power in the LaTeX printer 723 | [#12894](https://github.com/sympy/sympy/pull/12894) 724 | 725 | * Fix conversion of undefined functions (like `Function('f')`) to Sage. 726 | [#12826](https://github.com/sympy/sympy/pull/12826) 727 | 728 | * Fix wrong result integrating derivatives of multiple variables 729 | [#12971](https://github.com/sympy/sympy/pull/12971) 730 | 731 | * Rich comparison methods now return `NotImplemented` when comparing against 732 | unknown types [#13091](https://github.com/sympy/sympy/pull/13091) 733 | 734 | * Add/Fix logarithmic rewrites of inverse hyperbolic functions 735 | [#13099](https://github.com/sympy/sympy/pull/13099) 736 | 737 | * Fix Mul.expand(deep=False) 738 | [#13281](https://github.com/sympy/sympy/pull/13281) 739 | 740 | * Make DiracDelta(-x) == DiracDelta(x) 741 | [#13308](https://github.com/sympy/sympy/pull/13308) 742 | 743 | * `linsolve` now raises error for non-linear equations 744 | [#13325](https://github.com/sympy/sympy/pull/13325) 745 | 746 | * vlatex: added brackets in multiplication 747 | [#12299](https://github.com/sympy/sympy/pull/12299) 748 | 749 | * Add "The Zen of SymPy" (`import sympy.this`) 750 | 751 | * Install isympy using setuptools, making it available on Windows. isympy can 752 | also now be run as `python -m isympy` 753 | [#13193](https://github.com/sympy/sympy/pull/13193) 754 | 755 | * improve evaluation performance of `binomial` 756 | [#13484](https://github.com/sympy/sympy/pull/13484) 757 | 758 | * Add support for more than two operands in numpy logical and/or 759 | representations in lambdify 760 | [#12608](https://github.com/sympy/sympy/pull/12608) 761 | 762 | * The Mathematica parsing function has been rewritten 763 | [#13533](https://github.com/sympy/sympy/pull/13533) 764 | 765 | * The Mathematica parsing function now has an `additional_translations` flag 766 | [#13544](https://github.com/sympy/sympy/pull/13544) 767 | 768 | * Fix is_real for trigonometric and hyperbolic functions 769 | [#13678](https://github.com/sympy/sympy/pull/13678) 770 | 771 | * @/__matmul__ now only performs matrix multiplication 772 | [#13773](https://github.com/sympy/sympy/pull/13773) 773 | 774 | * Fold expressions with Piecewise before trying to integrate them 775 | [#13866](https://github.com/sympy/sympy/pull/13866) 776 | 777 | * Fix `has` for non-commutative Muls in some cases 778 | [#14026](https://github.com/sympy/sympy/pull/14026) 779 | 780 | * Avoid recursive calls for upper and lower gamma 781 | [#14021](https://github.com/sympy/sympy/pull/14021) 782 | 783 | * Remove the sympy_tokenize module in favor of the standard library tokenize. 784 | Fixes sympification of Python 3-only idioms, like `sympify('α')`. 785 | [#14085](https://github.com/sympy/sympy/pull/14085) 786 | 787 | * Check for logarithmic singularities in `_eval_interval` 788 | [#14097](https://github.com/sympy/sympy/pull/14097) 789 | 790 | * Simple optimization to speed up LCM 791 | [#14314](https://github.com/sympy/sympy/pull/14314) 792 | 793 | * Use extended Euclidean algorithm instead of totient calculation 794 | for modular matrix inverse 795 | [#14347](https://github.com/sympy/sympy/pull/14347) 796 | 797 | ## Authors 798 | 799 | The following people contributed at least one patch to this release (names are 800 | given in alphabetical order by last name). A total of 170 people 801 | contributed to this release. People with a * by their names contributed a 802 | patch for the first time for this release; 124 people contributed 803 | for the first time for this release. 804 | 805 | Thanks to everyone who contributed to this release! 806 | 807 | - Saurabh Agarwal* 808 | - Arif Ahmed 809 | - Jonathan Allan* 810 | - anca-mc* 811 | - Adwait Baokar* 812 | - Nilabja Bhattacharya* 813 | - Johan Blåbäck 814 | - Nicholas Bollweg* 815 | - Francesco Bonazzi 816 | - Matthew Brett 817 | - Marcin Briański 818 | - Bulat* 819 | - Ondřej Čertík 820 | - Arighna Chakrabarty* 821 | - Rishav Chakraborty* 822 | - Ravi charan* 823 | - Lev Chelyadinov* 824 | - Poom Chiarawongse* 825 | - James Cotton* 826 | - cym1* 827 | - czgdp1807* 828 | - Björn Dahlgren 829 | - David Daly* 830 | - der-blaue-elefant* 831 | - Gaurav Dhingra 832 | - dps7ud* 833 | - Rob Drynkin* 834 | - Seth Ebner* 835 | - Peter Enenkel* 836 | - Fredrik Eriksson* 837 | - Boris Ettinger* 838 | - eward* 839 | - Isuru Fernando 840 | - Segev Finer* 841 | - Caley Finn* 842 | - Micah Fitch* 843 | - Lucas Gallindo* 844 | - Bradley Gannon* 845 | - Mauro Garavello 846 | - Varun Garg 847 | - Ashish Kumar Gaurav* 848 | - Jithin D. George* 849 | - Sourav Ghosh* 850 | - Valeriia Gladkova 851 | - Jeremey Gluck* 852 | - Nikoleta Glynatsi* 853 | - Keno Goertz* 854 | - Nityananda Gohain* 855 | - Filip Gokstorp* 856 | - Sayan Goswami* 857 | - Jonathan A. Gross* 858 | - Theodore Han 859 | - Rupesh Harode* 860 | - James Harrop* 861 | - Rahil Hastu* 862 | - helo9* 863 | - Himanshu* 864 | - Hugo* 865 | - David Menéndez Hurtado* 866 | - Ayodeji Ige* 867 | - Itay4* 868 | - Harsh Jain 869 | - Samyak Jain 870 | - Saloni Jain* 871 | - Rohit Jain* 872 | - Shikhar Jaiswal 873 | - Mark Jeromin* 874 | - JMSS-Unknown* 875 | - Yathartha Joshi 876 | - Ishan Joshi* 877 | - Salil Vishnu Kapur* 878 | - Atharva Khare* 879 | - Abhigyan Khaund* 880 | - Sergey B Kirpichev 881 | - Leonid Kovalev 882 | - Amit Kumar 883 | - Jiri Kuncar 884 | - Akash Kundu* 885 | - Himanshu Ladia* 886 | - Rémy Léone* 887 | - Alex Lubbock* 888 | - Jared Lumpe* 889 | - luz.paz* 890 | - Colin B. Macdonald 891 | - Shubham Maheshwari* 892 | - Akshat Maheshwari* 893 | - Shikhar Makhija 894 | - Amit Manchanda* 895 | - Marco Mancini* 896 | - Cezary Marczak* 897 | - Yuki Matsuda* 898 | - Bhautik Mavani 899 | - Maxence Mayrand* 900 | - B McG* 901 | - Cavendish McKay* 902 | - Ehren Metcalfe* 903 | - Aaron Meurer 904 | - Peleg Michaeli 905 | - Szymon Mieszczak 906 | - Aaron Miller* 907 | - Joaquim Monserrat* 908 | - Jason Moore 909 | - Sidhant Nagpal* 910 | - Abdullah Javed Nesar 911 | - Javed Nissar* 912 | - Richard Otis 913 | - Austin Palmer* 914 | - Nikhil Pappu* 915 | - Fermi Paradox 916 | - Rhea Parekh* 917 | - Arihant Parsoya 918 | - Roberto Díaz Pérez* 919 | - Cristian Di Pietrantonio* 920 | - Waldir Pimenta* 921 | - Robert Pollak* 922 | - Alexander Pozdneev* 923 | - P. Sai Prasanth* 924 | - Rastislav Rabatin* 925 | - Zach Raines* 926 | - Shekhar Prasad Rajak 927 | - Juha Remes 928 | - Matthew Rocklin 929 | - Phil Ruffwind 930 | - rushyam* 931 | - Saketh* 932 | - Carl Sandrock* 933 | - Kshitij Saraogi 934 | - Nirmal Sarswat* 935 | - Subhash Saurabh* 936 | - Dmitry Savransky* 937 | - Nico Schlömer 938 | - Stan Schymanski* 939 | - sfoo* 940 | - Ayush Shridhar* 941 | - shruti 942 | - Sartaj Singh 943 | - Rajeev Singh* 944 | - Mayank Singh* 945 | - Arshdeep Singh* 946 | - Jashanpreet Singh* 947 | - Malkhan Singh* 948 | - Gleb Siroki* 949 | - Chris Smith 950 | - Ralf Stephan 951 | - Kalevi Suominen 952 | - Ian Swire* 953 | - symbolique* 954 | - Mihail Tarigradschi* 955 | - James Taylor* 956 | - Pavel Tkachenko* 957 | - Cédric Travelletti* 958 | - Tschijnmo TSCHAU 959 | - Wes Turner* 960 | - Unknown* 961 | - Akash Vaish* 962 | - vedantc98* 963 | - Pauli Virtanen 964 | - Vishal* 965 | - Stewart Wadsworth* 966 | - Ken Wakita* 967 | - Ethan Ward* 968 | - Matthew Wardrop* 969 | - Daniel Wennberg* 970 | - Lucas Wiman* 971 | - Kiyohito Yamazaki* 972 | - Yang Yang* 973 | - Ka Yi 974 | - ylemkimon* 975 | - zhouzq-thu* 976 | - Rob Zinkov* 977 | -------------------------------------------------------------------------------- /sympy_bot/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sympy/sympy-bot/4f457f788e1a2aeab35ecab3dadf7c508ff8101a/sympy_bot/tests/__init__.py -------------------------------------------------------------------------------- /sympy_bot/tests/test_changelog.py: -------------------------------------------------------------------------------- 1 | from ..changelog import format_change, format_authors 2 | 3 | def test_format_authors(): 4 | assert format_authors(['asmeurer']) == '[@asmeurer](https://github.com/asmeurer)' 5 | assert format_authors(['certik', 'asmeurer']) == '[@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik)' 6 | assert format_authors(['Upabjojr', 'certik', 'asmeurer']) == '[@asmeurer](https://github.com/asmeurer), [@certik](https://github.com/certik), and [@Upabjojr](https://github.com/Upabjojr)' 7 | 8 | def test_format_change(): 9 | assert format_change("* improved solvers", 123, ['certik', 'asmeurer']) == ' * improved solvers ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik))\n' 10 | assert format_change("* example\n```\n>>> print(1)\n1\n```", 123, ['asmeurer']) == ' * example\n ```\n >>> print(1)\n 1\n ```\n\n ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer))\n' 11 | assert format_change('* improved solvers\n\n * dsolve\n * solve', 123, ['asmeurer']) == ' * improved solvers\n\n * dsolve\n * solve\n\n ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer))\n' 12 | 13 | 14 | def test_threebackticks_not_multiline(): 15 | assert format_change('* added ```rot13``` and ```atbash``` ciphers', 16 | 16516, ['znxftw']) == ' * added ```rot13``` and ```atbash``` ciphers ([#16516](https://github.com/sympy/sympy/pull/16516) by [@znxftw](https://github.com/znxftw))\n' 17 | -------------------------------------------------------------------------------- /sympy_bot/tests/test_get_changelog.py: -------------------------------------------------------------------------------- 1 | from ..changelog import get_changelog 2 | 3 | def test_no_marker(): 4 | desc = """ 5 | * solvers 6 | * add a new solver 7 | """ 8 | 9 | status, message, changelogs = get_changelog(desc) 10 | 11 | assert not status 12 | assert '' in message 13 | assert not changelogs 14 | 15 | desc = """ 16 | NO ENTRY 17 | """ 18 | 19 | status, message, changelogs = get_changelog(desc) 20 | 21 | assert not status 22 | assert '' in message 23 | assert not changelogs 24 | 25 | def test_no_entry(): 26 | desc = """ 27 | 28 | NO ENTRY 29 | """ 30 | status, message, changelogs = get_changelog(desc) 31 | assert status 32 | assert "No release notes entry" in message 33 | assert not changelogs 34 | 35 | desc = """ 36 | 37 | * solvers 38 | * new solver 39 | 40 | NO ENTRY 41 | """ 42 | status, message, changelogs = get_changelog(desc) 43 | assert status 44 | assert "No release notes entry" in message 45 | assert not changelogs 46 | 47 | desc = """ 48 | 49 | * solvers 50 | 51 | NO ENTRY 52 | """ 53 | status, message, changelogs = get_changelog(desc) 54 | assert status, message 55 | assert "No release notes entry" in message 56 | assert not changelogs 57 | 58 | def test_headers(): 59 | desc = """ 60 | 61 | * solvers 62 | * new solver 63 | 64 | * core 65 | * faster core 66 | * better stuff 67 | 68 | """ 69 | status, message, changelogs = get_changelog(desc) 70 | assert status 71 | assert "good" in message 72 | assert changelogs == { 73 | 'solvers': ['* new solver'], 74 | 'core': ['* faster core', '* better stuff'] 75 | } 76 | 77 | def test_bad_headers(): 78 | desc = """ 79 | 80 | * new solver 81 | 82 | """ 83 | status, message, changelogs = get_changelog(desc) 84 | assert not status 85 | assert "subheader" in message 86 | assert not changelogs 87 | 88 | desc = """ 89 | 90 | * solvers 91 | 92 | """ 93 | status, message, changelogs = get_changelog(desc) 94 | assert not status 95 | assert "invalid" in message.lower() 96 | assert not changelogs 97 | 98 | desc = """ 99 | 100 | * new trig solvers 101 | 102 | """ 103 | status, message, changelogs = get_changelog(desc) 104 | assert not status 105 | assert "header" in message.lower() 106 | assert not changelogs 107 | 108 | desc = """ 109 | 110 | * invalid_header 111 | 112 | """ 113 | status, message, changelogs = get_changelog(desc) 114 | assert not status 115 | assert "header" in message.lower() 116 | assert "invalid_header" in message 117 | assert not changelogs 118 | 119 | def test_end_release_marker(): 120 | 121 | desc = """ 122 | 123 | * solvers 124 | * new trig solvers 125 | 126 | * stuff after 127 | """ 128 | status, message, changelogs = get_changelog(desc) 129 | assert status 130 | assert "good" in message 131 | assert changelogs == {'solvers': ['* new trig solvers']} 132 | 133 | desc = """ 134 | 135 | * solvers 136 | * new trig solvers 137 | 138 | * core 139 | * not a real change 140 | """ 141 | status, message, changelogs = get_changelog(desc) 142 | assert status 143 | assert "good" in message 144 | assert changelogs == {'solvers': ['* new trig solvers']} 145 | 146 | def test_multiline(): 147 | desc = """ 148 | 149 | * solvers 150 | * new trig solvers 151 | 152 | ``` 153 | code 154 | ``` 155 | * core 156 | * core change 157 | 158 | 159 | """ 160 | status, message, changelogs = get_changelog(desc) 161 | assert status 162 | assert "good" in message 163 | assert changelogs == { 164 | 'solvers': ['* new trig solvers\n\n ```\n code\n ```'], 165 | 'core': ['* core change'], 166 | } 167 | 168 | def test_threebackticks_not_multiline(): 169 | desc = """ 170 | 171 | * crypto 172 | * added ```rot13``` and ```atbash``` ciphers 173 | 174 | """ 175 | status, message, changelogs = get_changelog(desc) 176 | assert status 177 | assert "good" in message 178 | assert changelogs == { 179 | 'crypto': ['* added ```rot13``` and ```atbash``` ciphers'], 180 | } 181 | 182 | def test_multiple_multiline(): 183 | # from sympy/sympy#14758, see #14 184 | desc = """ 185 | 186 | * parsing 187 | * Added a submodule autolev which can be used to parse Autolev code to SymPy code. 188 | * physics.mechanics 189 | * Added a center of mass function in functions.py which returns the position vector of the center of 190 | mass of a system of bodies. 191 | * Added a corner case check in kane.py (Passes dummy symbols to q_ind and kd_eqs if not passed in 192 | to prevent errors which shouldn't occur). 193 | * physics.vector 194 | * Changed _w_diff_dcm in frame.py to get the correct results. 195 | 196 | """ 197 | status, message, changelogs = get_changelog(desc) 198 | assert status 199 | assert "good" in message 200 | assert changelogs == { 201 | 'parsing': [ 202 | '* Added a submodule autolev which can be used to parse Autolev code to SymPy code.', 203 | ], 204 | 'physics.mechanics': [ 205 | '* Added a center of mass function in functions.py which returns the position vector of the center of\n mass of a system of bodies.', 206 | "* Added a corner case check in kane.py (Passes dummy symbols to q_ind and kd_eqs if not passed in\n to prevent errors which shouldn't occur).", 207 | ], 208 | 'physics.vector': [ 209 | "* Changed _w_diff_dcm in frame.py to get the correct results.", 210 | ], 211 | } 212 | 213 | def test_empty_lines(): 214 | desc = """ 215 | 216 | 217 | * solvers 218 | 219 | * new solver 220 | 221 | * core 222 | 223 | * faster core 224 | 225 | * better stuff 226 | 227 | """ 228 | status, message, changelogs = get_changelog(desc) 229 | assert status 230 | assert "good" in message 231 | assert changelogs == { 232 | 'solvers': ['* new solver'], 233 | 'core': ['* faster core', '* better stuff'] 234 | } 235 | 236 | 237 | def test_mixed_bullets(): 238 | desc = r""" 239 | 240 | - solvers 241 | 242 | - new solver 243 | 244 | + core 245 | 246 | - faster core 247 | 248 | * better stuff 249 | 250 | + improved things 251 | 252 | """ 253 | status, message, changelogs = get_changelog(desc) 254 | assert status 255 | assert "good" in message 256 | assert changelogs == { 257 | 'solvers': ['- new solver'], 258 | 'core': ['- faster core', '* better stuff', '+ improved things'] 259 | } 260 | 261 | def test_empty(): 262 | desc = r""" 263 | 265 | 266 | #### References to other Issues or PRs 267 | 270 | 271 | 272 | #### Brief description of what is fixed or changed 273 | 274 | 275 | #### Other comments 276 | 277 | 278 | #### Release Notes 279 | 280 | 284 | 285 | 286 | 287 | 288 | """ 289 | status, message, changelogs = get_changelog(desc) 290 | assert not status 291 | assert 'No release notes were found' in message 292 | assert "" in message 293 | assert not changelogs 294 | 295 | def test_bad_bullet(): 296 | desc = r""" 297 | 298 | * core 299 | *`_atomic` can recurse into arguments 300 | 301 | """ 302 | status, message, changelogs = get_changelog(desc) 303 | assert not status 304 | assert "*`_atomic` can recurse into arguments" in message 305 | assert "Markdown bullet" in message 306 | assert not changelogs 307 | 308 | def test_bullet_not_indented_far_enough(): 309 | desc = r""" 310 | 311 | - solvers 312 | 313 | - new solver 314 | """ 315 | status, message, changelogs = get_changelog(desc) 316 | assert not status 317 | assert "indented" in message 318 | assert "- new solver" in message 319 | assert not changelogs 320 | 321 | def test_trailing_whitespace(): 322 | desc = """ 323 | 324 | - solvers \n\ 325 | \n\ 326 | - new solver \n\ 327 | """ 328 | 329 | status, message, changelogs = get_changelog(desc) 330 | assert status, message 331 | assert "good" in message 332 | assert changelogs == {"solvers": ["- new solver"]} 333 | 334 | def test_no_changelog(): 335 | desc = """ 336 | 337 | 338 | 339 | """ 340 | status, message, changelogs = get_changelog(desc) 341 | assert not status 342 | assert "No release notes were found" in message 343 | assert "" in message 344 | assert not changelogs 345 | 346 | desc = """ 347 | 348 | """ 349 | status, message, changelogs = get_changelog(desc) 350 | assert not status 351 | assert "No release notes were found" in message 352 | assert "" in message 353 | assert not changelogs 354 | -------------------------------------------------------------------------------- /sympy_bot/tests/test_update_release_notes.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ..changelog import get_release_notes_filename, update_release_notes 4 | 5 | def test_existing_header(): 6 | with open(os.path.join(os.path.dirname(__file__), 7 | get_release_notes_filename('1.2'))) as f: 8 | 9 | notes12 = f.read() 10 | 11 | changelogs = {'solvers': ['* solvers change 1', '* solvers change 2'], 'core': ['- core change 1']} 12 | authors = ['asmeurer', 'certik'] 13 | pr_number = '123' 14 | 15 | new_notes12 = update_release_notes(rel_notes_txt=notes12, changelogs=changelogs, pr_number=pr_number, authors=authors) 16 | 17 | assert new_notes12.splitlines()[114:119] == ['', '* core', ' - core change 1 ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik))', '', ' * Derivatives by a variable a symbolic number of times, like `diff(f(x), (x,'] 18 | 19 | assert new_notes12.splitlines()[614:620] == ['* solvers', ' * solvers change 1 ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik))', '', ' * solvers change 2 ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik))', '', ' * Enable initial condition solving in `dsolve`'] 20 | 21 | def test_new_header(): 22 | notes = """\ 23 | ## Changes 24 | 25 | ## Authors 26 | """ 27 | 28 | # Also makes sure they are inserted in the right order. 'other' should 29 | # stay last in submodules.txt 30 | changelogs = {'solvers': ['- solvers change'], 'other': ['- other changes']} 31 | authors = ['asmeurer'] 32 | pr_number = '123' 33 | 34 | new_notes = update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 35 | 36 | assert new_notes == """\ 37 | ## Changes 38 | 39 | * solvers 40 | - solvers change ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 41 | 42 | * other 43 | - other changes ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 44 | 45 | ## Authors 46 | """ 47 | 48 | 49 | def test_insert_new_header(): 50 | notes = """\ 51 | ## Changes 52 | 53 | * core 54 | * core change 55 | 56 | * solvers 57 | * solvers change 58 | 59 | ## Authors 60 | """ 61 | 62 | # 'other' should stay last in submodules.txt 63 | changelogs = {'sets': ['- sets change'], 'calculus': ['- calculus change'], 'other': ['- other changes']} 64 | authors = ['asmeurer'] 65 | pr_number = '123' 66 | 67 | new_notes = update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 68 | 69 | assert new_notes == """\ 70 | ## Changes 71 | 72 | * calculus 73 | - calculus change ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 74 | 75 | * core 76 | * core change 77 | 78 | * sets 79 | - sets change ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 80 | 81 | * solvers 82 | * solvers change 83 | 84 | * other 85 | - other changes ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 86 | 87 | ## Authors 88 | """, new_notes 89 | 90 | def test_no_changes_header(): 91 | notes = """\ 92 | 93 | ## Authors 94 | """ 95 | 96 | # Also makes sure they are inserted in the right order. 'other' should 97 | # stay last in submodules.txt 98 | changelogs = {'solvers': ['- solvers change'], 'other': ['- other changes']} 99 | authors = ['asmeurer'] 100 | pr_number = '123' 101 | 102 | try: 103 | update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 104 | except RuntimeError as e: 105 | assert "## Changes" in e.args[0] 106 | else: 107 | raise AssertionError("Did not raise") 108 | 109 | notes = "" 110 | 111 | try: 112 | update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 113 | except RuntimeError as e: 114 | assert "## Changes" in e.args[0] 115 | else: 116 | raise AssertionError("Did not raise") 117 | 118 | def test_no_authors_header(): 119 | notes = "## Changes" 120 | 121 | changelogs = {'solvers': ['* solvers change 1', '* solvers change 2'], 'core': ['- core change 1']} 122 | authors = ['asmeurer', 'certik'] 123 | pr_number = '123' 124 | 125 | try: 126 | update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 127 | except RuntimeError as e: 128 | assert "## Authors" in e.args[0] 129 | else: 130 | raise AssertionError("Did not raise") 131 | 132 | def test_before_changes_header(): 133 | # This is based on what happened to the 1.4 release notes (#35), except 134 | # with "## Major Changes" replaced with "## Changes" 135 | notes = """ 136 | 137 | ## Backwards compatibility breaks and deprecations 138 | 139 | **Please manually add any backwards compatibility breaks or 140 | [deprecations](https://github.com/sympy/sympy/wiki/Deprecating-policy) here, 141 | in addition to the automatic listing below.** 142 | 143 | * printing 144 | * Old behavior of `theanocode.theano_function` when passed a single-element list as the `outputs` parameter has been deprecated. Use `squeeze=False` to enable the new behavior (the created function will return a single-element list also). To get a function that returns a single value not wrapped in a list, pass a single expression not wrapped in a list to `outputs` (e.g. `theano_function([x, y], x + y)` instead of `theano_function([x, y], [x + y])`). See [#14986](https://github.com/sympy/sympy/issues/14986). 145 | 146 | ## Changes 147 | 148 | * printing 149 | * Added `squeeze=False` option to `theanocode.theano_function` to give more consistent sequence-in-sequence-out behavior, keep old behavior as default for now but deprecate it. ([#14949](https://github.com/sympy/sympy/pull/14949) by [@jlumpe](https://github.com/jlumpe)) 150 | 151 | * Added `scalar=True` option to `theanocode.theano_function` to create a function which returns scalars instead of 0-dimensional arrays. ([#14949](https://github.com/sympy/sympy/pull/14949) by [@jlumpe](https://github.com/jlumpe)) 152 | 153 | * series 154 | * implemented expression-based recursive sequence class ([#15184](https://github.com/sympy/sympy/pull/15184) by [@rwbogl](https://github.com/rwbogl)) 155 | 156 | ## Authors 157 | """ 158 | 159 | pr_number = "15207" 160 | authors = ["sylee957"] 161 | changelogs = {'matrices': ["- Added `iszerofunc` parameter for `_eval_det_bareiss()`"]} 162 | 163 | new_notes = update_release_notes(rel_notes_txt=notes, changelogs=changelogs, pr_number=pr_number, authors=authors) 164 | 165 | assert new_notes == """ 166 | 167 | ## Backwards compatibility breaks and deprecations 168 | 169 | **Please manually add any backwards compatibility breaks or 170 | [deprecations](https://github.com/sympy/sympy/wiki/Deprecating-policy) here, 171 | in addition to the automatic listing below.** 172 | 173 | * printing 174 | * Old behavior of `theanocode.theano_function` when passed a single-element list as the `outputs` parameter has been deprecated. Use `squeeze=False` to enable the new behavior (the created function will return a single-element list also). To get a function that returns a single value not wrapped in a list, pass a single expression not wrapped in a list to `outputs` (e.g. `theano_function([x, y], x + y)` instead of `theano_function([x, y], [x + y])`). See [#14986](https://github.com/sympy/sympy/issues/14986). 175 | 176 | ## Changes 177 | 178 | * matrices 179 | - Added `iszerofunc` parameter for `_eval_det_bareiss()` ([#15207](https://github.com/sympy/sympy/pull/15207) by [@sylee957](https://github.com/sylee957)) 180 | 181 | * printing 182 | * Added `squeeze=False` option to `theanocode.theano_function` to give more consistent sequence-in-sequence-out behavior, keep old behavior as default for now but deprecate it. ([#14949](https://github.com/sympy/sympy/pull/14949) by [@jlumpe](https://github.com/jlumpe)) 183 | 184 | * Added `scalar=True` option to `theanocode.theano_function` to create a function which returns scalars instead of 0-dimensional arrays. ([#14949](https://github.com/sympy/sympy/pull/14949) by [@jlumpe](https://github.com/jlumpe)) 185 | 186 | * series 187 | * implemented expression-based recursive sequence class ([#15184](https://github.com/sympy/sympy/pull/15184) by [@rwbogl](https://github.com/rwbogl)) 188 | 189 | ## Authors 190 | """ 191 | 192 | 193 | def test_multiline_indent(): 194 | # See test_multiple_multiline() in test_get_changelogs.py and issue #14. 195 | # This is from sympy/sympy#14758. 196 | pr_number = '14758' 197 | authors = ['NikhilPappu'] 198 | notes = """\ 199 | ## Changes 200 | 201 | ## Authors 202 | """ 203 | 204 | changelogs = { 205 | 'parsing': [ 206 | '* Added a submodule autolev which can be used to parse Autolev code to SymPy code.', 207 | ], 208 | 'physics.mechanics': [ 209 | '* Added a center of mass function in functions.py which returns the position vector of the center of\n mass of a system of bodies.', 210 | "* Added a corner case check in kane.py (Passes dummy symbols to q_ind and kd_eqs if not passed in\n to prevent errors which shouldn't occur).", 211 | ], 212 | 'physics.vector': [ 213 | "* Changed _w_diff_dcm in frame.py to get the correct results.", 214 | ], 215 | } 216 | 217 | assert update_release_notes(rel_notes_txt=notes, changelogs=changelogs, 218 | pr_number=pr_number, authors=authors) == """\ 219 | ## Changes 220 | 221 | * parsing 222 | * Added a submodule autolev which can be used to parse Autolev code to SymPy code. ([#14758](https://github.com/sympy/sympy/pull/14758) by [@NikhilPappu](https://github.com/NikhilPappu)) 223 | 224 | * physics.mechanics 225 | * Added a center of mass function in functions.py which returns the position vector of the center of 226 | mass of a system of bodies. ([#14758](https://github.com/sympy/sympy/pull/14758) by [@NikhilPappu](https://github.com/NikhilPappu)) 227 | 228 | * Added a corner case check in kane.py (Passes dummy symbols to q_ind and kd_eqs if not passed in 229 | to prevent errors which shouldn't occur). ([#14758](https://github.com/sympy/sympy/pull/14758) by [@NikhilPappu](https://github.com/NikhilPappu)) 230 | 231 | * physics.vector 232 | * Changed _w_diff_dcm in frame.py to get the correct results. ([#14758](https://github.com/sympy/sympy/pull/14758) by [@NikhilPappu](https://github.com/NikhilPappu)) 233 | 234 | ## Authors 235 | """ 236 | -------------------------------------------------------------------------------- /sympy_bot/tests/test_update_wiki.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | # This is required for the tests to run properly 5 | import pytest_mock 6 | pytest_mock 7 | 8 | from ..update_wiki import update_wiki, run 9 | 10 | token = "TOKEN" 11 | 12 | TESTING_TOKEN = os.environ.get('TESTING_TOKEN', None) 13 | 14 | def test_run(mocker, capsys): 15 | mocker.patch.dict(os.environ, {"GH_AUTH": token}) 16 | 17 | run(['echo', token]) 18 | captured = capsys.readouterr() 19 | assert captured.out == 'echo ~~~~~\n~~~~~\n\n' 20 | assert captured.err == '' 21 | 22 | @pytest.mark.skipif(True, reason="The update wiki test needs to be updated. See https://github.com/sympy/sympy-bot/issues/98") 23 | def test_update_wiki(mocker): 24 | mocker.patch.dict(os.environ, {"GH_AUTH": TESTING_TOKEN}) 25 | 26 | travis_build = os.environ.get("TRAVIS_JOB_ID") 27 | travis_url = f"https://travis-ci.org/sympy/sympy-bot/builds/{travis_build}" if travis_build else "unknown" 28 | update_wiki( 29 | wiki_url='https://github.com/sympy/sympy-bot.wiki', 30 | release_notes_file='Release-Notes-Tests.md', 31 | changelogs={'other': [f'* Release notes update from {travis_url}']}, 32 | pr_number='14942', 33 | authors=['asmeurer'], 34 | ) 35 | -------------------------------------------------------------------------------- /sympy_bot/tests/test_util_functions.py: -------------------------------------------------------------------------------- 1 | from ..changelog import get_release_notes_filename, format_change 2 | 3 | def test_get_release_notes_filename(): 4 | assert get_release_notes_filename('1.1') == 'Release-Notes-for-1.1.md' 5 | assert get_release_notes_filename('1.1rc1') == 'Release-Notes-for-1.1.md' 6 | assert get_release_notes_filename('1.1.rc1') == 'Release-Notes-for-1.1.md' 7 | assert get_release_notes_filename('1.1dev') == 'Release-Notes-for-1.1.md' 8 | assert get_release_notes_filename('1.1.dev') == 'Release-Notes-for-1.1.md' 9 | 10 | assert get_release_notes_filename('1.1.1') == 'Release-Notes-for-1.1.1.md' 11 | assert get_release_notes_filename('1.1.1rc1') == 'Release-Notes-for-1.1.1.md' 12 | assert get_release_notes_filename('1.1.1rc1') == 'Release-Notes-for-1.1.1.md' 13 | assert get_release_notes_filename('1.1.1dev') == 'Release-Notes-for-1.1.1.md' 14 | assert get_release_notes_filename('1.1.1.dev') == 'Release-Notes-for-1.1.1.md' 15 | 16 | def test_format_change(): 17 | change = '* modified some stuff' 18 | pr_number = '123' 19 | authors = ['asmeurer'] 20 | assert format_change(change, pr_number, authors) == \ 21 | ' * modified some stuff ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer))\n' 22 | 23 | authors = ['asmeurer', 'certik'] 24 | assert format_change(change, pr_number, authors) == \ 25 | ' * modified some stuff ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik))\n' 26 | 27 | authors = ['asmeurer', 'certik', 'sympy'] 28 | assert format_change(change, pr_number, authors) == \ 29 | ' * modified some stuff ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer), [@certik](https://github.com/certik), and [@sympy](https://github.com/sympy))\n' 30 | 31 | def test_format_change_multiline(): 32 | change = '* new trig solvers\n\n ```\n code\n ```' 33 | authors = ['asmeurer'] 34 | pr_number = '123' 35 | 36 | assert format_change(change, pr_number, authors) == """\ 37 | * new trig solvers 38 | 39 | ``` 40 | code 41 | ``` 42 | 43 | ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 44 | """ 45 | 46 | change = '* new trig solvers\n ```\n code\n ```' 47 | authors = ['asmeurer'] 48 | pr_number = '123' 49 | 50 | assert format_change(change, pr_number, authors) == """\ 51 | * new trig solvers 52 | ``` 53 | code 54 | ``` 55 | 56 | ([#123](https://github.com/sympy/sympy/pull/123) by [@asmeurer](https://github.com/asmeurer)) 57 | """ 58 | 59 | # Make sure changes that aren't really multiline don't get the PR and 60 | # author link on a separate line. From 61 | # https://github.com/sympy/sympy/pull/15133 62 | change = '* Used checksysodesol in test_ode.py to reduce amount of code. Also slightly modified the\n representation of the test cases in the function, but with no changes in their values.' 63 | authors = ['sudz123'] 64 | pr_number = '15133' 65 | 66 | assert format_change(change, pr_number, authors) == """\ 67 | * Used checksysodesol in test_ode.py to reduce amount of code. Also slightly modified the\n representation of the test cases in the function, but with no changes in their values. ([#15133](https://github.com/sympy/sympy/pull/15133) by [@sudz123](https://github.com/sudz123)) 68 | """ 69 | -------------------------------------------------------------------------------- /sympy_bot/tests/test_webapp.py: -------------------------------------------------------------------------------- 1 | """ 2 | The tests here test the webapp by sending fake requests through a fake GH 3 | object and checking that the right API calls were made. 4 | 5 | Each fake request has just the API information currently needed by the webapp, 6 | so if more API information is used, it will need to be added. 7 | 8 | The GitHub API docs are useful: 9 | 10 | - Pull request event (the main input to the webapp): 11 | https://developer.github.com/v3/activity/events/types/#pullrequestevent 12 | - Pull request object (the 'pull_request' key to the pull request event): 13 | https://developer.github.com/v3/pulls/ 14 | - Commit objects (the output from the 'commits_url'): 15 | https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request 16 | - Comment objects (the output from the 'comments_url'): 17 | https://developer.github.com/v3/issues/comments/ 18 | - Contents objects (the output from the version_url): 19 | https://developer.github.com/v3/repos/contents/ 20 | - Statuses objects (the output from statuses_url): 21 | https://developer.github.com/v3/repos/statuses/ 22 | 23 | """ 24 | 25 | import datetime 26 | import base64 27 | from subprocess import CalledProcessError 28 | import os 29 | 30 | from gidgethub import sansio 31 | 32 | from ..webapp import router 33 | 34 | # These are required for the tests to run properly 35 | import pytest_aiohttp 36 | pytest_aiohttp 37 | import pytest_mock 38 | pytest_mock 39 | 40 | from pytest import mark, raises 41 | parametrize = mark.parametrize 42 | 43 | class FakeRateLimit: 44 | def __init__(self, *, remaining=5000, limit=5000, reset_datetime=None): 45 | self.remaining = remaining 46 | self.limit = limit 47 | now = datetime.datetime.now(datetime.timezone.utc) 48 | self.reset_datetime = reset_datetime or now + datetime.timedelta(hours=1) 49 | 50 | class FakeGH: 51 | """ 52 | Faked gh object 53 | 54 | Arguments: 55 | 56 | - getitem: dictionary mapping {url: result}, or None 57 | - getiter: dictionary mapping {url: result}, or None 58 | - rate_limit: FakeRateLimit object, or None 59 | - post: dictionary mapping {url: result}, or None 60 | - patch: dictionary mapping {url: result}, or None 61 | - delete: dictionary mapping {url: result}, or None 62 | 63 | The results are stored in the properties 64 | 65 | - getiter_urls: list of urls called with getiter 66 | - getitem_urls: list of urls called with getitem 67 | - post_urls: list of urls called with post 68 | - post_data: list of the data input for each post 69 | - patch_urls: list of urls called with patch 70 | - patch_data: list of the data input for each patch 71 | - delete_urls: list of urls called with delete 72 | - rate_limit: the FakeRateLimit object 73 | 74 | Note that GET requests are cached in the code and may be called multiple 75 | times. 76 | """ 77 | def __init__(self, *, getitem=None, getiter=None, rate_limit=None, 78 | post=None, patch=None, delete=None): 79 | self._getitem_return = getitem 80 | self._getiter_return = getiter 81 | self._post_return = post 82 | self._patch_return = patch 83 | self._delete_return = delete 84 | self.getiter_urls = [] 85 | self.getitem_urls = [] 86 | self.post_urls = [] 87 | self.post_data = [] 88 | self.patch_urls = [] 89 | self.patch_data = [] 90 | self.delete_urls = [] 91 | self.rate_limit = rate_limit or FakeRateLimit() 92 | 93 | async def getitem(self, url): 94 | self.getitem_urls.append(url) 95 | return self._getitem_return[url] 96 | 97 | async def getiter(self, url): 98 | self.getiter_urls.append(url) 99 | for item in self._getiter_return[url]: 100 | yield item 101 | 102 | async def post(self, url, *, data): 103 | self.post_urls.append(url) 104 | self.post_data.append(data) 105 | return self._post_return[url] 106 | 107 | async def patch(self, url, *, data): 108 | self.patch_urls.append(url) 109 | self.patch_data.append(data) 110 | return self._patch_return[url] 111 | 112 | async def delete(self, url): 113 | self.delete_urls.append(url) 114 | return self._delete_return[url] 115 | 116 | def _assert_gh_is_empty(gh): 117 | assert gh._getitem_return == None 118 | assert gh._getiter_return == None 119 | assert gh._post_return == None 120 | assert gh.getiter_urls == [] 121 | assert gh.getitem_urls == [] 122 | assert gh.post_urls == [] 123 | assert gh.post_data == [] 124 | assert gh.patch_urls == [] 125 | assert gh.patch_data == [] 126 | assert gh.delete_urls == [] 127 | 128 | def _event(data): 129 | return sansio.Event(data, event='pull_request', delivery_id='1') 130 | 131 | version = '1.2.1' 132 | release_notes_file = 'Release-Notes-for-1.2.1.md' 133 | comments_url = 'https://api.github.com/repos/sympy/sympy/pulls/1/comments' 134 | commits_url = 'https://api.github.com/repos/sympy/sympy/pulls/1/commits' 135 | contents_url = 'https://api.github.com/repos/sympy/sympy/contents/{+path}' 136 | sha = 'a109f824f4cb2b1dd97cf832f329d59da00d609a' 137 | commit_url_template = 'https://api.github.com/repos/sympy/sympy/commits/{sha}' 138 | commit_url = commit_url_template.format(sha=sha) 139 | version_url_template = 'https://api.github.com/repos/sympy/sympy/contents/sympy/release.py?ref={ref}' 140 | version_url = version_url_template.format(ref='master') 141 | html_url = "https://github.com/sympy/sympy" 142 | wiki_url = "https://github.com/sympy/sympy.wiki" 143 | comment_html_url = 'https://github.com/sympy/sympy/pulls/1#issuecomment-1' 144 | comment_html_url2 = 'https://github.com/sympy/sympy/pulls/1#issuecomment-2' 145 | statuses_url = "https://api.github.com/repos/sympy/sympy/statuses/4a09f9f253c7372ec857774b1fe114b1266013fe" 146 | existing_comment_url = "https://api.github.com/repos/sympy/sympy/issues/comments/1" 147 | existing_added_deleted_comment_url = "https://api.github.com/repos/sympy/sympy/issues/comments/2" 148 | pr_number = 1 149 | 150 | valid_PR_description = """ 151 | 152 | * solvers 153 | * new trig solvers 154 | 155 | """ 156 | 157 | valid_PR_description_no_entry = """ 158 | 159 | NO ENTRY 160 | 161 | """ 162 | 163 | invalid_PR_description = """ 164 | 165 | 166 | 167 | """ 168 | 169 | release_notes_comment_body = """\ 170 | :white_check_mark: 171 | 172 | Hi, I am the [SymPy bot](https://github.com/sympy/sympy-bot) (version not found!). I'm here to help you write a release notes entry. Please read the [guide on how to write release notes](https://github.com/sympy/sympy/wiki/Writing-Release-Notes). 173 | 174 | 175 | 176 | Your release notes are in good order. 177 | 178 | Here is what the release notes will look like: 179 | * solvers 180 | * new trig solvers ([#1](https://github.com/sympy/sympy/pull/1) by [@asmeurer](https://github.com/asmeurer) and [@certik](https://github.com/certik)) 181 | 182 | This will be added to https://github.com/sympy/sympy/wiki/Release-Notes-for-1.2.1. 183 | 184 |
Click here to see the pull request description that was parsed. 185 | 186 | 187 | 188 | * solvers 189 | * new trig solvers 190 | 191 | 192 | 193 |

194 | """ 195 | 196 | added_deleted_comment_body = """\ 197 | ### \U0001f7e0 198 | 199 | Hi, I am the [SymPy bot](https://github.com/sympy/sympy-bot) (version not found!). I've noticed that some of your commits add or delete files. Since this is sometimes done unintentionally, I wanted to alert you about it. 200 | 201 | This is an experimental feature of SymPy Bot. If you have any feedback on it, please comment at https://github.com/sympy/sympy-bot/issues/75. 202 | 203 | The following commits **add new files**: 204 | * 174b8b37bc33e9eb29e710a233190d02a13bdb54: 205 | - `file1` 206 | 207 | The following commits **delete files**: 208 | * a109f824f4cb2b1dd97cf832f329d59da00d609a: 209 | - `file1` 210 | 211 | If these files were added/deleted on purpose, you can ignore this message. 212 | """ 213 | 214 | @parametrize('action', ['closed', 'synchronize', 'edited']) 215 | @parametrize('merged', [True, False]) 216 | async def test_no_action_on_closed_prs(action, merged): 217 | if action == 'closed' and merged == True: 218 | return 219 | gh = FakeGH() 220 | event_data = { 221 | 'pull_request': { 222 | 'number': 1, 223 | 'state': 'closed', 224 | 'merged': merged, 225 | }, 226 | } 227 | event_data['action'] = action 228 | 229 | event = _event(event_data) 230 | 231 | res = await router.dispatch(event, gh) 232 | assert res is None 233 | _assert_gh_is_empty(gh) 234 | 235 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 236 | async def test_status_good_new_comment(action): 237 | event_data = { 238 | 'pull_request': { 239 | 'number': 1, 240 | 'state': 'open', 241 | 'merged': False, 242 | 'comments_url': comments_url, 243 | 'commits_url': commits_url, 244 | 'head': { 245 | 'user': { 246 | 'login': 'asmeurer', 247 | }, 248 | }, 249 | 'base': { 250 | 'repo': { 251 | 'contents_url': contents_url, 252 | 'html_url': html_url, 253 | }, 254 | 'ref': 'master', 255 | }, 256 | 'body': valid_PR_description, 257 | 'statuses_url': statuses_url, 258 | }, 259 | 'action': action, 260 | } 261 | 262 | 263 | commits = [ 264 | { 265 | 'author': { 266 | 'login': 'asmeurer', 267 | }, 268 | 'commit': { 269 | 'message': "A good commit", 270 | }, 271 | 'sha': sha, 272 | 'url': commit_url, 273 | }, 274 | { 275 | 'author': { 276 | 'login': 'certik', 277 | }, 278 | 'commit': { 279 | 'message': "A good commit", 280 | }, 281 | 'sha': sha, 282 | 'url': commit_url, 283 | }, 284 | # Test commits without a login 285 | { 286 | 'author': None, 287 | 'commit': { 288 | 'message': "A good commit", 289 | }, 290 | 'sha': sha, 291 | 'url': commit_url, 292 | }, 293 | ] 294 | 295 | commit = { 296 | 'files': [ 297 | { 298 | 'status': 'modified', 299 | }, 300 | ], 301 | 'parents': [ 302 | { 303 | "url": commit_url, 304 | "sha": sha, 305 | }, 306 | ], 307 | } 308 | 309 | # No comment from sympy-bot 310 | comments = [ 311 | { 312 | 'user': { 313 | 'login': 'asmeurer', 314 | }, 315 | }, 316 | { 317 | 'user': { 318 | 'login': 'certik', 319 | }, 320 | }, 321 | ] 322 | 323 | version_file = { 324 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 325 | } 326 | 327 | getiter = { 328 | commits_url: commits, 329 | comments_url: comments, 330 | } 331 | 332 | getitem = { 333 | version_url: version_file, 334 | commit_url: commit, 335 | } 336 | post = { 337 | comments_url: { 338 | 'html_url': comment_html_url, 339 | }, 340 | statuses_url: {}, 341 | } 342 | 343 | event = _event(event_data) 344 | 345 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 346 | 347 | await router.dispatch(event, gh) 348 | 349 | getitem_urls = gh.getitem_urls 350 | getiter_urls = gh.getiter_urls 351 | post_urls = gh.post_urls 352 | post_data = gh.post_data 353 | patch_urls = gh.patch_urls 354 | patch_data = gh.patch_data 355 | 356 | assert set(getiter_urls) == set(getiter) 357 | assert set(getitem_urls) == set(getitem) 358 | assert post_urls == [comments_url, statuses_url] 359 | assert len(post_data) == 2 360 | # Comments data 361 | assert post_data[0].keys() == {"body"} 362 | comment = post_data[0]["body"] 363 | assert ":white_check_mark:" in comment 364 | assert ":x:" not in comment 365 | assert "new trig solvers" in comment 366 | assert "error" not in comment 367 | assert "https://github.com/sympy/sympy-bot" in comment 368 | for line in valid_PR_description: 369 | assert line in comment 370 | assert "good order" in comment 371 | # Statuses data 372 | assert post_data[1] == { 373 | "state": "success", 374 | "target_url": comment_html_url, 375 | "description": "The release notes look OK", 376 | "context": "sympy-bot/release-notes", 377 | } 378 | assert patch_urls == [] 379 | assert patch_data == [] 380 | 381 | 382 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 383 | async def test_status_good_existing_comment(action): 384 | event_data = { 385 | 'pull_request': { 386 | 'number': 1, 387 | 'state': 'open', 388 | 'merged': False, 389 | 'comments_url': comments_url, 390 | 'commits_url': commits_url, 391 | 'head': { 392 | 'user': { 393 | 'login': 'asmeurer', 394 | }, 395 | }, 396 | 'base': { 397 | 'repo': { 398 | 'contents_url': contents_url, 399 | 'html_url': html_url, 400 | }, 401 | 'ref': 'master', 402 | }, 403 | 'body': valid_PR_description, 404 | 'statuses_url': statuses_url, 405 | }, 406 | 'action': action, 407 | } 408 | 409 | commits = [ 410 | { 411 | 'author': { 412 | 'login': 'asmeurer', 413 | }, 414 | 'commit': { 415 | 'message': "A good commit", 416 | }, 417 | 'sha': sha, 418 | 'url': commit_url, 419 | }, 420 | { 421 | 'author': { 422 | 'login': 'certik', 423 | }, 424 | 'commit': { 425 | 'message': "A good commit", 426 | }, 427 | 'sha': sha, 428 | 'url': commit_url, 429 | }, 430 | # Test commits without a login 431 | { 432 | 'author': None, 433 | 'commit': { 434 | 'message': "A good commit", 435 | }, 436 | 'sha': sha, 437 | 'url': commit_url, 438 | }, 439 | ] 440 | 441 | commit = { 442 | 'files': [ 443 | { 444 | 'status': 'modified', 445 | }, 446 | ], 447 | 'parents': [ 448 | { 449 | "url": commit_url, 450 | "sha": sha, 451 | }, 452 | ], 453 | } 454 | 455 | # Has comment from sympy-bot 456 | comments = [ 457 | { 458 | 'user': { 459 | 'login': 'sympy-bot', 460 | }, 461 | 'url': existing_comment_url, 462 | 'body': release_notes_comment_body, 463 | }, 464 | { 465 | 'user': { 466 | 'login': 'asmeurer', 467 | }, 468 | 'body': "comment", 469 | }, 470 | { 471 | 'user': { 472 | 'login': 'certik', 473 | }, 474 | "body": "comment", 475 | }, 476 | ] 477 | 478 | version_file = { 479 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 480 | } 481 | 482 | getiter = { 483 | commits_url: commits, 484 | comments_url: comments, 485 | } 486 | 487 | getitem = { 488 | version_url: version_file, 489 | commit_url: commit, 490 | } 491 | post = { 492 | statuses_url: {}, 493 | } 494 | 495 | patch = { 496 | existing_comment_url: { 497 | 'html_url': comment_html_url, 498 | }, 499 | } 500 | 501 | event = _event(event_data) 502 | 503 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 504 | 505 | await router.dispatch(event, gh) 506 | 507 | getitem_urls = gh.getitem_urls 508 | getiter_urls = gh.getiter_urls 509 | post_urls = gh.post_urls 510 | post_data = gh.post_data 511 | patch_urls = gh.patch_urls 512 | patch_data = gh.patch_data 513 | 514 | assert set(getiter_urls) == set(getiter) 515 | assert set(getitem_urls) == set(getitem) 516 | assert post_urls == [statuses_url] 517 | # Statuses data 518 | assert post_data == [{ 519 | "state": "success", 520 | "target_url": comment_html_url, 521 | "description": "The release notes look OK", 522 | "context": "sympy-bot/release-notes", 523 | }] 524 | # Comments data 525 | assert patch_urls == [existing_comment_url] 526 | assert len(patch_data) == 1 527 | assert patch_data[0].keys() == {"body"} 528 | comment = patch_data[0]["body"] 529 | assert ":white_check_mark:" in comment 530 | assert ":x:" not in comment 531 | assert "new trig solvers" in comment 532 | assert "error" not in comment 533 | assert "https://github.com/sympy/sympy-bot" in comment 534 | for line in valid_PR_description: 535 | assert line in comment 536 | assert "good order" in comment 537 | 538 | 539 | @parametrize('action', ['closed']) 540 | async def test_closed_with_merging(mocker, action): 541 | # Based on test_status_good_existing_comment 542 | 543 | update_wiki_called_kwargs = {} 544 | def mocked_update_wiki(*args, **kwargs): 545 | nonlocal update_wiki_called_kwargs 546 | assert not args # All args are keyword-only 547 | update_wiki_called_kwargs = kwargs 548 | 549 | mocker.patch('sympy_bot.webapp.update_wiki', mocked_update_wiki) 550 | 551 | event_data = { 552 | 'pull_request': { 553 | 'number': 1, 554 | 'state': 'open', 555 | 'merged': True, 556 | 'comments_url': comments_url, 557 | 'commits_url': commits_url, 558 | 'head': { 559 | 'user': { 560 | 'login': 'asmeurer', 561 | }, 562 | }, 563 | 'base': { 564 | 'repo': { 565 | 'contents_url': contents_url, 566 | 'html_url': html_url, 567 | }, 568 | 'ref': 'master', 569 | }, 570 | 'body': valid_PR_description, 571 | 'statuses_url': statuses_url, 572 | }, 573 | 'action': action, 574 | } 575 | 576 | 577 | commits = [ 578 | { 579 | 'author': { 580 | 'login': 'asmeurer', 581 | }, 582 | 'commit': { 583 | 'message': "A good commit", 584 | }, 585 | 'sha': sha, 586 | 'url': commit_url, 587 | }, 588 | { 589 | 'author': { 590 | 'login': 'certik', 591 | }, 592 | 'commit': { 593 | 'message': "A good commit", 594 | }, 595 | 'sha': sha, 596 | 'url': commit_url, 597 | }, 598 | # Test commits without a login 599 | { 600 | 'author': None, 601 | 'commit': { 602 | 'message': "A good commit", 603 | }, 604 | 'sha': sha, 605 | 'url': commit_url, 606 | }, 607 | ] 608 | 609 | # Has comment from sympy-bot 610 | comments = [ 611 | { 612 | 'user': { 613 | 'login': 'sympy-bot', 614 | }, 615 | 'url': existing_comment_url, 616 | 'body': release_notes_comment_body, 617 | }, 618 | { 619 | 'user': { 620 | 'login': 'asmeurer', 621 | }, 622 | 'body': "comment", 623 | }, 624 | { 625 | 'user': { 626 | 'login': 'certik', 627 | }, 628 | 'body': "comment", 629 | }, 630 | ] 631 | 632 | version_file = { 633 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 634 | } 635 | 636 | getiter = { 637 | commits_url: commits, 638 | comments_url: comments, 639 | } 640 | 641 | getitem = { 642 | version_url: version_file, 643 | } 644 | post = { 645 | statuses_url: {}, 646 | } 647 | 648 | patch = { 649 | existing_comment_url: { 650 | 'html_url': comment_html_url, 651 | 'body': release_notes_comment_body, 652 | 'url': existing_comment_url, 653 | }, 654 | } 655 | 656 | event = _event(event_data) 657 | 658 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 659 | 660 | await router.dispatch(event, gh) 661 | 662 | getitem_urls = gh.getitem_urls 663 | getiter_urls = gh.getiter_urls 664 | post_urls = gh.post_urls 665 | post_data = gh.post_data 666 | patch_urls = gh.patch_urls 667 | patch_data = gh.patch_data 668 | 669 | assert set(getiter_urls) == set(getiter), getiter_urls 670 | assert set(getitem_urls) == set(getitem) 671 | assert post_urls == [statuses_url] 672 | # Statuses data 673 | assert post_data == [{ 674 | "state": "success", 675 | "target_url": comment_html_url, 676 | "description": "The release notes look OK", 677 | "context": "sympy-bot/release-notes", 678 | }] 679 | # Comments data 680 | assert patch_urls == [existing_comment_url, existing_comment_url] 681 | assert len(patch_data) == 2 682 | assert patch_data[0].keys() == {"body"} 683 | comment = patch_data[0]["body"] 684 | assert comment == release_notes_comment_body 685 | assert ":white_check_mark:" in comment 686 | assert ":x:" not in comment 687 | assert "new trig solvers" in comment 688 | assert "error" not in comment 689 | assert "https://github.com/sympy/sympy-bot" in comment 690 | for line in valid_PR_description: 691 | assert line in comment 692 | assert "good order" in comment 693 | updated_comment = patch_data[1]['body'] 694 | assert updated_comment.startswith(comment) 695 | assert "have been updated" in updated_comment 696 | 697 | assert update_wiki_called_kwargs == { 698 | 'wiki_url': wiki_url, 699 | 'release_notes_file': release_notes_file, 700 | 'changelogs': {'solvers': ['* new trig solvers']}, 701 | 'pr_number': pr_number, 702 | 'authors': ['asmeurer', 'certik'], 703 | } 704 | 705 | 706 | 707 | @parametrize('action', ['closed']) 708 | async def test_closed_with_merging_no_entry(mocker, action): 709 | # Based on test_status_good_existing_comment 710 | 711 | update_wiki_called_kwargs = {} 712 | def mocked_update_wiki(*args, **kwargs): 713 | nonlocal update_wiki_called_kwargs 714 | assert not args # All args are keyword-only 715 | update_wiki_called_kwargs = kwargs 716 | 717 | mocker.patch('sympy_bot.webapp.update_wiki', mocked_update_wiki) 718 | 719 | event_data = { 720 | 'pull_request': { 721 | 'number': 1, 722 | 'state': 'open', 723 | 'merged': True, 724 | 'comments_url': comments_url, 725 | 'commits_url': commits_url, 726 | 'head': { 727 | 'user': { 728 | 'login': 'asmeurer', 729 | }, 730 | }, 731 | 'base': { 732 | 'repo': { 733 | 'contents_url': contents_url, 734 | 'html_url': html_url, 735 | }, 736 | 'ref': 'master', 737 | }, 738 | 'body': valid_PR_description_no_entry, 739 | 'statuses_url': statuses_url, 740 | }, 741 | 'action': action, 742 | } 743 | 744 | commits = [ 745 | { 746 | 'author': { 747 | 'login': 'asmeurer', 748 | }, 749 | 'commit': { 750 | 'message': "A good commit", 751 | }, 752 | 'sha': sha, 753 | 'url': commit_url, 754 | }, 755 | { 756 | 'author': { 757 | 'login': 'certik', 758 | }, 759 | 'commit': { 760 | 'message': "A good commit", 761 | }, 762 | 'sha': sha, 763 | 'url': commit_url, 764 | }, 765 | # Test commits without a login 766 | { 767 | 'author': None, 768 | 'commit': { 769 | 'message': "A good commit", 770 | }, 771 | 'sha': sha, 772 | 'url': commit_url, 773 | }, 774 | ] 775 | 776 | # Has comment from sympy-bot 777 | comments = [ 778 | { 779 | 'user': { 780 | 'login': 'sympy-bot', 781 | }, 782 | 'url': existing_comment_url, 783 | 'body': release_notes_comment_body, 784 | }, 785 | { 786 | 'user': { 787 | 'login': 'asmeurer', 788 | }, 789 | 'body': "comment", 790 | }, 791 | { 792 | 'user': { 793 | 'login': 'certik', 794 | }, 795 | 'body': "comment", 796 | }, 797 | ] 798 | 799 | version_file = { 800 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 801 | } 802 | 803 | getiter = { 804 | commits_url: commits, 805 | comments_url: comments, 806 | } 807 | 808 | getitem = { 809 | version_url: version_file, 810 | } 811 | post = { 812 | statuses_url: {}, 813 | } 814 | 815 | patch = { 816 | existing_comment_url: { 817 | 'html_url': comment_html_url, 818 | 'body': release_notes_comment_body, 819 | 'url': existing_comment_url, 820 | }, 821 | } 822 | 823 | event = _event(event_data) 824 | 825 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 826 | 827 | await router.dispatch(event, gh) 828 | 829 | getitem_urls = gh.getitem_urls 830 | getiter_urls = gh.getiter_urls 831 | post_urls = gh.post_urls 832 | post_data = gh.post_data 833 | patch_urls = gh.patch_urls 834 | patch_data = gh.patch_data 835 | 836 | assert set(getiter_urls) == set(getiter), getiter_urls 837 | assert set(getitem_urls) == set(getitem) 838 | assert post_urls == [statuses_url] 839 | # Statuses data 840 | assert post_data == [{ 841 | "state": "success", 842 | "target_url": comment_html_url, 843 | "description": "The release notes look OK", 844 | "context": "sympy-bot/release-notes", 845 | }] 846 | # Comments data 847 | assert patch_urls == [existing_comment_url] 848 | assert len(patch_data) == 1 849 | assert patch_data[0].keys() == {"body"} 850 | comment = patch_data[0]["body"] 851 | assert "No release notes entry will be added for this pull request." in comment 852 | assert ":white_check_mark:" in comment 853 | assert ":x:" not in comment 854 | assert "error" not in comment 855 | assert "https://github.com/sympy/sympy-bot" in comment 856 | for line in valid_PR_description: 857 | assert line in comment 858 | 859 | assert update_wiki_called_kwargs == {} 860 | 861 | @parametrize('action', ['closed']) 862 | @parametrize('exception', [RuntimeError('error message'), 863 | CalledProcessError(1, 'cmd')]) 864 | async def test_closed_with_merging_update_wiki_error(mocker, action, exception): 865 | # Based on test_closed_with_merging 866 | 867 | update_wiki_called_kwargs = {} 868 | def mocked_update_wiki(*args, **kwargs): 869 | nonlocal update_wiki_called_kwargs 870 | assert not args # All args are keyword-only 871 | update_wiki_called_kwargs = kwargs 872 | raise exception 873 | 874 | mocker.patch('sympy_bot.webapp.update_wiki', mocked_update_wiki) 875 | mocker.patch.dict(os.environ, {"GH_AUTH": "TESTING TOKEN"}) 876 | 877 | event_data = { 878 | 'pull_request': { 879 | 'number': 1, 880 | 'state': 'open', 881 | 'merged': True, 882 | 'comments_url': comments_url, 883 | 'commits_url': commits_url, 884 | 'head': { 885 | 'user': { 886 | 'login': 'asmeurer', 887 | }, 888 | }, 889 | 'base': { 890 | 'repo': { 891 | 'contents_url': contents_url, 892 | 'html_url': html_url, 893 | }, 894 | 'ref': 'master', 895 | }, 896 | 'body': valid_PR_description, 897 | 'statuses_url': statuses_url, 898 | }, 899 | 'action': action, 900 | } 901 | 902 | 903 | commits = [ 904 | { 905 | 'author': { 906 | 'login': 'asmeurer', 907 | }, 908 | 'commit': { 909 | 'message': "A good commit", 910 | }, 911 | 'sha': sha, 912 | 'url': commit_url, 913 | }, 914 | { 915 | 'author': { 916 | 'login': 'certik', 917 | }, 918 | 'commit': { 919 | 'message': "A good commit", 920 | }, 921 | 'sha': sha, 922 | 'url': commit_url, 923 | }, 924 | # Test commits without a login 925 | { 926 | 'author': None, 927 | 'commit': { 928 | 'message': "A good commit", 929 | }, 930 | 'sha': sha, 931 | 'url': commit_url, 932 | }, 933 | ] 934 | 935 | # Has comment from sympy-bot 936 | comments = [ 937 | { 938 | 'user': { 939 | 'login': 'sympy-bot', 940 | }, 941 | 'url': existing_comment_url, 942 | 'body': release_notes_comment_body, 943 | }, 944 | { 945 | 'user': { 946 | 'login': 'asmeurer', 947 | }, 948 | 'body': "comment", 949 | }, 950 | { 951 | 'user': { 952 | 'login': 'certik', 953 | }, 954 | 'body': "comment", 955 | }, 956 | ] 957 | 958 | version_file = { 959 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 960 | } 961 | 962 | getiter = { 963 | commits_url: commits, 964 | comments_url: comments, 965 | } 966 | 967 | getitem = { 968 | version_url: version_file, 969 | } 970 | post = { 971 | statuses_url: {}, 972 | comments_url: { 973 | 'html_url': comment_html_url, 974 | }, 975 | } 976 | 977 | patch = { 978 | existing_comment_url: { 979 | 'html_url': comment_html_url, 980 | 'body': release_notes_comment_body, 981 | 'url': existing_comment_url, 982 | }, 983 | } 984 | 985 | event = _event(event_data) 986 | 987 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 988 | 989 | with raises(type(exception)): 990 | await router.dispatch(event, gh) 991 | 992 | getitem_urls = gh.getitem_urls 993 | getiter_urls = gh.getiter_urls 994 | post_urls = gh.post_urls 995 | post_data = gh.post_data 996 | patch_urls = gh.patch_urls 997 | patch_data = gh.patch_data 998 | 999 | assert set(getiter_urls) == set(getiter), getiter_urls 1000 | assert set(getitem_urls) == set(getitem) 1001 | assert post_urls == [statuses_url, comments_url, statuses_url] 1002 | # Statuses data 1003 | assert len(post_data) == 3 1004 | assert post_data[0] == { 1005 | "state": "success", 1006 | "target_url": comment_html_url, 1007 | "description": "The release notes look OK", 1008 | "context": "sympy-bot/release-notes", 1009 | } 1010 | assert post_data[1].keys() == {'body'} 1011 | error_message = post_data[1]['body'] 1012 | assert ':rotating_light:' in error_message 1013 | assert 'ERROR' in error_message 1014 | assert 'https://github.com/sympy/sympy-bot/issues' in error_message 1015 | if isinstance(exception, RuntimeError): 1016 | assert 'error message' in error_message 1017 | else: 1018 | assert "Command 'cmd' returned non-zero exit status 1." in error_message 1019 | assert post_data[2] == { 1020 | "state": "error", 1021 | "target_url": comment_html_url, 1022 | "description": "There was an error updating the release notes on the wiki.", 1023 | "context": "sympy-bot/release-notes", 1024 | } 1025 | # Comments data 1026 | assert patch_urls == [existing_comment_url] 1027 | assert len(patch_data) == 1 1028 | assert patch_data[0].keys() == {"body"} 1029 | comment = patch_data[0]["body"] 1030 | assert comment == release_notes_comment_body 1031 | assert ":white_check_mark:" in comment 1032 | assert ":x:" not in comment 1033 | assert "new trig solvers" in comment 1034 | assert "error" not in comment 1035 | assert "https://github.com/sympy/sympy-bot" in comment 1036 | for line in valid_PR_description: 1037 | assert line in comment 1038 | assert "good order" in comment 1039 | 1040 | assert update_wiki_called_kwargs == { 1041 | 'wiki_url': wiki_url, 1042 | 'release_notes_file': release_notes_file, 1043 | 'changelogs': {'solvers': ['* new trig solvers']}, 1044 | 'pr_number': pr_number, 1045 | 'authors': ['asmeurer', 'certik'], 1046 | } 1047 | 1048 | 1049 | 1050 | @parametrize('action', ['closed']) 1051 | async def test_closed_with_merging_bad_status_error(mocker, action): 1052 | # Based on test_closed_with_merging 1053 | 1054 | update_wiki_called_kwargs = {} 1055 | def mocked_update_wiki(*args, **kwargs): 1056 | nonlocal update_wiki_called_kwargs 1057 | assert not args # All args are keyword-only 1058 | update_wiki_called_kwargs = kwargs 1059 | 1060 | mocker.patch('sympy_bot.webapp.update_wiki', mocked_update_wiki) 1061 | mocker.patch.dict(os.environ, {"GH_AUTH": "TESTING TOKEN"}) 1062 | 1063 | event_data = { 1064 | 'pull_request': { 1065 | 'number': 1, 1066 | 'state': 'open', 1067 | 'merged': True, 1068 | 'comments_url': comments_url, 1069 | 'commits_url': commits_url, 1070 | 'head': { 1071 | 'user': { 1072 | 'login': 'asmeurer', 1073 | }, 1074 | }, 1075 | 'base': { 1076 | 'repo': { 1077 | 'contents_url': contents_url, 1078 | 'html_url': html_url, 1079 | }, 1080 | 'ref': 'master', 1081 | }, 1082 | 'body': invalid_PR_description, 1083 | 'statuses_url': statuses_url, 1084 | }, 1085 | 'action': action, 1086 | } 1087 | 1088 | 1089 | commits = [ 1090 | { 1091 | 'author': { 1092 | 'login': 'asmeurer', 1093 | }, 1094 | 'commit': { 1095 | 'message': "A good commit", 1096 | }, 1097 | 'sha': sha, 1098 | 'url': commit_url, 1099 | }, 1100 | { 1101 | 'author': { 1102 | 'login': 'certik', 1103 | }, 1104 | 'commit': { 1105 | 'message': "A good commit", 1106 | }, 1107 | 'sha': sha, 1108 | 'url': commit_url, 1109 | }, 1110 | # Test commits without a login 1111 | { 1112 | 'author': None, 1113 | 'commit': { 1114 | 'message': "A good commit", 1115 | }, 1116 | 'sha': sha, 1117 | 'url': commit_url, 1118 | }, 1119 | ] 1120 | 1121 | # Has comment from sympy-bot 1122 | comments = [ 1123 | { 1124 | 'user': { 1125 | 'login': 'sympy-bot', 1126 | }, 1127 | 'url': existing_comment_url, 1128 | 'body': release_notes_comment_body, 1129 | }, 1130 | { 1131 | 'user': { 1132 | 'login': 'asmeurer', 1133 | }, 1134 | 'body': "comment", 1135 | }, 1136 | { 1137 | 'user': { 1138 | 'login': 'certik', 1139 | }, 1140 | 'body': "comment", 1141 | }, 1142 | ] 1143 | 1144 | getiter = { 1145 | commits_url: commits, 1146 | comments_url: comments, 1147 | } 1148 | 1149 | getitem = {} 1150 | post = { 1151 | statuses_url: {}, 1152 | comments_url: { 1153 | 'html_url': comment_html_url, 1154 | }, 1155 | } 1156 | 1157 | patch = { 1158 | existing_comment_url: { 1159 | 'html_url': comment_html_url, 1160 | 'body': release_notes_comment_body, 1161 | 'url': existing_comment_url, 1162 | }, 1163 | } 1164 | 1165 | event = _event(event_data) 1166 | 1167 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 1168 | 1169 | await router.dispatch(event, gh) 1170 | 1171 | getitem_urls = gh.getitem_urls 1172 | getiter_urls = gh.getiter_urls 1173 | post_urls = gh.post_urls 1174 | post_data = gh.post_data 1175 | patch_urls = gh.patch_urls 1176 | patch_data = gh.patch_data 1177 | 1178 | assert set(getiter_urls) == set(getiter), getiter_urls 1179 | assert set(getitem_urls) == set(getitem) 1180 | assert post_urls == [statuses_url, comments_url, statuses_url] 1181 | # Statuses data 1182 | assert len(post_data) == 3 1183 | assert post_data[0] == { 1184 | "state": "failure", 1185 | "target_url": comment_html_url, 1186 | "description": "The release notes check failed", 1187 | "context": "sympy-bot/release-notes", 1188 | } 1189 | assert post_data[1].keys() == {'body'} 1190 | error_message = post_data[1]['body'] 1191 | assert ':rotating_light:' in error_message 1192 | assert 'ERROR' in error_message 1193 | assert 'https://github.com/sympy/sympy-bot/issues' in error_message 1194 | assert "The pull request was merged even though the release notes bot had a failing status." in error_message 1195 | 1196 | assert post_data[2] == { 1197 | "state": "error", 1198 | "target_url": comment_html_url, 1199 | "description": "There was an error updating the release notes on the wiki.", 1200 | "context": "sympy-bot/release-notes", 1201 | } 1202 | # Comments data 1203 | assert patch_urls == [existing_comment_url] 1204 | assert len(patch_data) == 1 1205 | assert patch_data[0].keys() == {"body"} 1206 | comment = patch_data[0]["body"] 1207 | assert ":white_check_mark:" not in comment 1208 | assert ":x:" in comment 1209 | assert "new trig solvers" not in comment 1210 | assert "error" not in comment 1211 | assert "There was an issue" in comment 1212 | assert "https://github.com/sympy/sympy-bot" in comment 1213 | for line in invalid_PR_description: 1214 | assert line in comment 1215 | assert "good order" not in comment 1216 | assert "No release notes were found" in comment, comment 1217 | 1218 | assert update_wiki_called_kwargs == {} 1219 | 1220 | 1221 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1222 | async def test_status_bad_new_comment(action): 1223 | event_data = { 1224 | 'pull_request': { 1225 | 'number': 1, 1226 | 'state': 'open', 1227 | 'merged': False, 1228 | 'comments_url': comments_url, 1229 | 'commits_url': commits_url, 1230 | 'head': { 1231 | 'user': { 1232 | 'login': 'asmeurer', 1233 | }, 1234 | }, 1235 | 'base': { 1236 | 'repo': { 1237 | 'contents_url': contents_url, 1238 | 'html_url': html_url, 1239 | }, 1240 | 'ref': 'master', 1241 | }, 1242 | 'body': invalid_PR_description, 1243 | 'statuses_url': statuses_url, 1244 | }, 1245 | 'action': action, 1246 | } 1247 | 1248 | 1249 | commits = [ 1250 | { 1251 | 'author': { 1252 | 'login': 'asmeurer', 1253 | }, 1254 | 'commit': { 1255 | 'message': "A good commit", 1256 | }, 1257 | 'sha': sha, 1258 | 'url': commit_url, 1259 | }, 1260 | { 1261 | 'author': { 1262 | 'login': 'certik', 1263 | }, 1264 | 'commit': { 1265 | 'message': "A good commit", 1266 | }, 1267 | 'sha': sha, 1268 | 'url': commit_url, 1269 | }, 1270 | # Test commits without a login 1271 | { 1272 | 'author': None, 1273 | 'commit': { 1274 | 'message': "A good commit", 1275 | }, 1276 | 'sha': sha, 1277 | 'url': commit_url, 1278 | }, 1279 | ] 1280 | 1281 | commit = { 1282 | 'files': [ 1283 | { 1284 | 'status': 'modified', 1285 | }, 1286 | ], 1287 | 'parents': [ 1288 | { 1289 | "url": commit_url, 1290 | "sha": sha, 1291 | }, 1292 | ], 1293 | } 1294 | 1295 | # No comment from sympy-bot 1296 | comments = [ 1297 | { 1298 | 'user': { 1299 | 'login': 'asmeurer', 1300 | }, 1301 | 'body': "comment", 1302 | }, 1303 | { 1304 | 'user': { 1305 | 'login': 'certik', 1306 | }, 1307 | 'body': "comment", 1308 | }, 1309 | ] 1310 | 1311 | getiter = { 1312 | commits_url: commits, 1313 | comments_url: comments, 1314 | } 1315 | 1316 | getitem = { 1317 | commit_url: commit, 1318 | } 1319 | post = { 1320 | comments_url: { 1321 | 'html_url': comment_html_url, 1322 | }, 1323 | statuses_url: {}, 1324 | } 1325 | 1326 | event = _event(event_data) 1327 | 1328 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 1329 | 1330 | await router.dispatch(event, gh) 1331 | 1332 | getitem_urls = gh.getitem_urls 1333 | getiter_urls = gh.getiter_urls 1334 | post_urls = gh.post_urls 1335 | post_data = gh.post_data 1336 | patch_urls = gh.patch_urls 1337 | patch_data = gh.patch_data 1338 | 1339 | assert set(getiter_urls) == set(getiter) 1340 | assert set(getitem_urls) == set(getitem) 1341 | assert post_urls == [comments_url, statuses_url] 1342 | assert len(post_data) == 2 1343 | # Comments data 1344 | assert post_data[0].keys() == {"body"} 1345 | comment = post_data[0]["body"] 1346 | assert ":white_check_mark:" not in comment 1347 | assert ":x:" in comment 1348 | assert "new trig solvers" not in comment 1349 | assert "error" not in comment 1350 | assert "There was an issue" in comment 1351 | assert "https://github.com/sympy/sympy-bot" in comment 1352 | for line in invalid_PR_description: 1353 | assert line in comment 1354 | assert "good order" not in comment 1355 | assert "No release notes were found" in comment 1356 | # Statuses data 1357 | assert post_data[1] == { 1358 | "state": "failure", 1359 | "target_url": comment_html_url, 1360 | "description": "The release notes check failed", 1361 | "context": "sympy-bot/release-notes", 1362 | } 1363 | assert patch_urls == [] 1364 | assert patch_data == [] 1365 | 1366 | 1367 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1368 | async def test_status_bad_existing_comment(action): 1369 | event_data = { 1370 | 'pull_request': { 1371 | 'number': 1, 1372 | 'state': 'open', 1373 | 'merged': False, 1374 | 'comments_url': comments_url, 1375 | 'commits_url': commits_url, 1376 | 'head': { 1377 | 'user': { 1378 | 'login': 'asmeurer', 1379 | }, 1380 | }, 1381 | 'base': { 1382 | 'repo': { 1383 | 'contents_url': contents_url, 1384 | 'html_url': html_url, 1385 | }, 1386 | 'ref': 'master', 1387 | }, 1388 | 'body': invalid_PR_description, 1389 | 'statuses_url': statuses_url, 1390 | }, 1391 | 'action': action, 1392 | } 1393 | 1394 | 1395 | commits = [ 1396 | { 1397 | 'author': { 1398 | 'login': 'asmeurer', 1399 | }, 1400 | 'commit': { 1401 | 'message': "A good commit", 1402 | }, 1403 | 'sha': sha, 1404 | 'url': commit_url, 1405 | }, 1406 | { 1407 | 'author': { 1408 | 'login': 'certik', 1409 | }, 1410 | 'commit': { 1411 | 'message': "A good commit", 1412 | }, 1413 | 'sha': sha, 1414 | 'url': commit_url, 1415 | }, 1416 | # Test commits without a login 1417 | { 1418 | 'author': None, 1419 | 'commit': { 1420 | 'message': "A good commit", 1421 | }, 1422 | 'sha': sha, 1423 | 'url': commit_url, 1424 | }, 1425 | ] 1426 | 1427 | commit = { 1428 | 'files': [ 1429 | { 1430 | 'status': 'modified', 1431 | }, 1432 | ], 1433 | 'parents': [ 1434 | { 1435 | "url": commit_url, 1436 | "sha": sha, 1437 | }, 1438 | ], 1439 | } 1440 | 1441 | # Has comment from sympy-bot 1442 | comments = [ 1443 | { 1444 | 'user': { 1445 | 'login': 'sympy-bot', 1446 | }, 1447 | 'url': existing_comment_url, 1448 | 'body': release_notes_comment_body, 1449 | }, 1450 | { 1451 | 'user': { 1452 | 'login': 'asmeurer', 1453 | }, 1454 | 'body': "comment", 1455 | }, 1456 | { 1457 | 'user': { 1458 | 'login': 'certik', 1459 | }, 1460 | 'body': "comment", 1461 | }, 1462 | ] 1463 | 1464 | getiter = { 1465 | commits_url: commits, 1466 | comments_url: comments, 1467 | } 1468 | 1469 | getitem = { 1470 | commit_url: commit, 1471 | } 1472 | post = { 1473 | statuses_url: {}, 1474 | } 1475 | 1476 | patch = { 1477 | existing_comment_url: { 1478 | 'html_url': comment_html_url, 1479 | }, 1480 | } 1481 | 1482 | event = _event(event_data) 1483 | 1484 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 1485 | 1486 | await router.dispatch(event, gh) 1487 | 1488 | getitem_urls = gh.getitem_urls 1489 | getiter_urls = gh.getiter_urls 1490 | post_urls = gh.post_urls 1491 | post_data = gh.post_data 1492 | patch_urls = gh.patch_urls 1493 | patch_data = gh.patch_data 1494 | 1495 | assert set(getiter_urls) == set(getiter) 1496 | assert set(getitem_urls) == set(getitem) 1497 | assert post_urls == [statuses_url] 1498 | # Statuses data 1499 | assert post_data == [{ 1500 | "state": "failure", 1501 | "target_url": comment_html_url, 1502 | "description": "The release notes check failed", 1503 | "context": "sympy-bot/release-notes", 1504 | }] 1505 | # Comments data 1506 | assert patch_urls == [existing_comment_url] 1507 | assert len(patch_data) == 1 1508 | assert patch_data[0].keys() == {"body"} 1509 | comment = patch_data[0]["body"] 1510 | assert ":white_check_mark:" not in comment 1511 | assert ":x:" in comment 1512 | assert "new trig solvers" not in comment 1513 | assert "error" not in comment 1514 | assert "There was an issue" in comment 1515 | assert "https://github.com/sympy/sympy-bot" in comment 1516 | for line in invalid_PR_description: 1517 | assert line in comment 1518 | assert "good order" not in comment 1519 | assert "No release notes were found" in comment, comment 1520 | 1521 | 1522 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1523 | async def test_rate_limit_comment(action): 1524 | # Based on test_status_good_new_comment 1525 | event_data = { 1526 | 'pull_request': { 1527 | 'number': 1, 1528 | 'state': 'open', 1529 | 'merged': False, 1530 | 'comments_url': comments_url, 1531 | 'commits_url': commits_url, 1532 | 'head': { 1533 | 'user': { 1534 | 'login': 'asmeurer', 1535 | }, 1536 | }, 1537 | 'base': { 1538 | 'repo': { 1539 | 'contents_url': contents_url, 1540 | 'html_url': html_url, 1541 | }, 1542 | 'ref': 'master', 1543 | }, 1544 | 'body': valid_PR_description, 1545 | 'statuses_url': statuses_url, 1546 | }, 1547 | 'action': action, 1548 | } 1549 | 1550 | 1551 | commits = [ 1552 | { 1553 | 'author': { 1554 | 'login': 'asmeurer', 1555 | }, 1556 | 'commit': { 1557 | 'message': "A good commit", 1558 | }, 1559 | 'sha': sha, 1560 | 'url': commit_url, 1561 | }, 1562 | { 1563 | 'author': { 1564 | 'login': 'certik', 1565 | }, 1566 | 'commit': { 1567 | 'message': "A good commit", 1568 | }, 1569 | 'sha': sha, 1570 | 'url': commit_url, 1571 | }, 1572 | # Test commits without a login 1573 | { 1574 | 'author': None, 1575 | 'commit': { 1576 | 'message': "A good commit", 1577 | }, 1578 | 'sha': sha, 1579 | 'url': commit_url, 1580 | }, 1581 | ] 1582 | 1583 | commit = { 1584 | 'files': [ 1585 | { 1586 | 'status': 'modified', 1587 | }, 1588 | ], 1589 | 'parents': [ 1590 | { 1591 | "url": commit_url, 1592 | "sha": sha, 1593 | }, 1594 | ], 1595 | } 1596 | 1597 | # No comment from sympy-bot 1598 | comments = [ 1599 | { 1600 | 'user': { 1601 | 'login': 'asmeurer', 1602 | }, 1603 | 'body': "comment", 1604 | }, 1605 | { 1606 | 'user': { 1607 | 'login': 'certik', 1608 | }, 1609 | "body": "comment", 1610 | }, 1611 | ] 1612 | 1613 | version_file = { 1614 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 1615 | } 1616 | 1617 | getiter = { 1618 | commits_url: commits, 1619 | comments_url: comments, 1620 | } 1621 | 1622 | getitem = { 1623 | version_url: version_file, 1624 | commit_url: commit, 1625 | } 1626 | post = { 1627 | comments_url: { 1628 | 'html_url': comment_html_url, 1629 | }, 1630 | statuses_url: {}, 1631 | } 1632 | 1633 | event = _event(event_data) 1634 | 1635 | now = datetime.datetime.now(datetime.timezone.utc) 1636 | reset_datetime = now + datetime.timedelta(hours=1) 1637 | rate_limit = FakeRateLimit(remaining=5, limit=1000, reset_datetime=reset_datetime) 1638 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, rate_limit=rate_limit) 1639 | 1640 | await router.dispatch(event, gh) 1641 | 1642 | # Everything else is already tested in test_status_good_new_comment() 1643 | # above 1644 | post_urls = gh.post_urls 1645 | post_data = gh.post_data 1646 | assert post_urls == [comments_url, statuses_url, comments_url] 1647 | assert len(post_data) == 3 1648 | assert post_data[2].keys() == {"body"} 1649 | comment = post_data[2]["body"] 1650 | assert ":warning:" in comment 1651 | assert "5" in comment 1652 | assert "1000" in comment 1653 | assert str(reset_datetime) in comment 1654 | 1655 | 1656 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1657 | async def test_header_in_message(action): 1658 | # Based on test_status_good_new_comment 1659 | event_data = { 1660 | 'pull_request': { 1661 | 'number': 1, 1662 | 'state': 'open', 1663 | 'merged': False, 1664 | 'comments_url': comments_url, 1665 | 'commits_url': commits_url, 1666 | 'head': { 1667 | 'user': { 1668 | 'login': 'asmeurer', 1669 | }, 1670 | }, 1671 | 'base': { 1672 | 'repo': { 1673 | 'contents_url': contents_url, 1674 | 'html_url': html_url, 1675 | }, 1676 | 'ref': 'master', 1677 | }, 1678 | 'body': valid_PR_description, 1679 | 'statuses_url': statuses_url, 1680 | }, 1681 | 'action': action, 1682 | } 1683 | 1684 | sha_1 = '174b8b37bc33e9eb29e710a233190d02a13bdb54' 1685 | commits = [ 1686 | { 1687 | 'author': { 1688 | 'login': 'asmeurer', 1689 | }, 1690 | 'commit': { 1691 | 'message': """ 1692 | 1693 | * solvers 1694 | * solver change 1695 | 1696 | """ 1697 | }, 1698 | 'sha': sha_1, 1699 | 'url': commit_url_template.format(sha=sha_1) 1700 | }, 1701 | { 1702 | 'author': { 1703 | 'login': 'certik', 1704 | }, 1705 | 'commit': { 1706 | 'message': "A good commit", 1707 | }, 1708 | 'sha': sha, 1709 | 'url': commit_url, 1710 | }, 1711 | # Test commits without a login 1712 | { 1713 | 'author': None, 1714 | 'commit': { 1715 | 'message': "A good commit", 1716 | }, 1717 | 'sha': sha, 1718 | 'url': commit_url, 1719 | }, 1720 | ] 1721 | 1722 | commit = { 1723 | 'files': [ 1724 | { 1725 | 'status': 'modified', 1726 | }, 1727 | ], 1728 | 'parents': [ 1729 | { 1730 | "url": commit_url, 1731 | "sha": sha, 1732 | }, 1733 | ], 1734 | } 1735 | 1736 | # No comment from sympy-bot 1737 | comments = [ 1738 | { 1739 | 'user': { 1740 | 'login': 'asmeurer', 1741 | }, 1742 | 'body': "comment", 1743 | }, 1744 | { 1745 | 'user': { 1746 | 'login': 'certik', 1747 | }, 1748 | 'body': "comment", 1749 | }, 1750 | ] 1751 | 1752 | getiter = { 1753 | commits_url: commits, 1754 | comments_url: comments, 1755 | } 1756 | 1757 | getitem = { 1758 | commit_url: commit, 1759 | commit_url_template.format(sha=sha_1): commit, 1760 | } 1761 | post = { 1762 | comments_url: { 1763 | 'html_url': comment_html_url, 1764 | }, 1765 | statuses_url: {}, 1766 | } 1767 | 1768 | event = _event(event_data) 1769 | 1770 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 1771 | 1772 | await router.dispatch(event, gh) 1773 | 1774 | getitem_urls = gh.getitem_urls 1775 | getiter_urls = gh.getiter_urls 1776 | post_urls = gh.post_urls 1777 | post_data = gh.post_data 1778 | patch_urls = gh.patch_urls 1779 | patch_data = gh.patch_data 1780 | 1781 | # The rest is already tested in test_status_good_new_comment 1782 | assert set(getiter_urls) == set(getiter) 1783 | assert set(getitem_urls) == set(getitem) 1784 | assert post_urls == [comments_url, statuses_url] 1785 | assert len(post_data) == 2 1786 | # Comments data 1787 | assert post_data[0].keys() == {"body"} 1788 | comment = post_data[0]["body"] 1789 | assert ":white_check_mark:" not in comment 1790 | assert ":x:" in comment 1791 | assert "error" not in comment 1792 | assert "https://github.com/sympy/sympy-bot" in comment 1793 | assert "good order" not in comment 1794 | assert sha_1 in comment 1795 | assert "" in comment 1796 | assert "" in comment 1797 | # Statuses data 1798 | assert post_data[1] == { 1799 | "state": "failure", 1800 | "target_url": comment_html_url, 1801 | "description": "The release notes check failed", 1802 | "context": "sympy-bot/release-notes", 1803 | } 1804 | assert patch_urls == [] 1805 | assert patch_data == [] 1806 | 1807 | 1808 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1809 | async def test_bad_version_file(action): 1810 | event_data = { 1811 | 'pull_request': { 1812 | 'number': 1, 1813 | 'state': 'open', 1814 | 'merged': False, 1815 | 'comments_url': comments_url, 1816 | 'commits_url': commits_url, 1817 | 'head': { 1818 | 'user': { 1819 | 'login': 'asmeurer', 1820 | }, 1821 | }, 1822 | 'base': { 1823 | 'repo': { 1824 | 'contents_url': contents_url, 1825 | 'html_url': html_url, 1826 | }, 1827 | 'ref': 'master', 1828 | }, 1829 | 'body': valid_PR_description, 1830 | 'statuses_url': statuses_url, 1831 | }, 1832 | 'action': action, 1833 | } 1834 | 1835 | 1836 | commits = [ 1837 | { 1838 | 'author': { 1839 | 'login': 'asmeurer', 1840 | }, 1841 | 'commit': { 1842 | 'message': "A good commit", 1843 | }, 1844 | 'sha': sha, 1845 | 'url': commit_url, 1846 | }, 1847 | { 1848 | 'author': { 1849 | 'login': 'certik', 1850 | }, 1851 | 'commit': { 1852 | 'message': "A good commit", 1853 | }, 1854 | 'sha': sha, 1855 | 'url': commit_url, 1856 | }, 1857 | # Test commits without a login 1858 | { 1859 | 'author': None, 1860 | 'commit': { 1861 | 'message': "A good commit", 1862 | }, 1863 | 'sha': sha, 1864 | 'url': commit_url, 1865 | }, 1866 | ] 1867 | 1868 | commit = { 1869 | 'files': [ 1870 | { 1871 | 'status': 'modified', 1872 | }, 1873 | ], 1874 | 'parents': [ 1875 | { 1876 | "url": commit_url, 1877 | "sha": sha, 1878 | }, 1879 | ], 1880 | } 1881 | 1882 | # No comment from sympy-bot 1883 | comments = [ 1884 | { 1885 | 'user': { 1886 | 'login': 'asmeurer', 1887 | }, 1888 | }, 1889 | { 1890 | 'user': { 1891 | 'login': 'certik', 1892 | }, 1893 | }, 1894 | ] 1895 | 1896 | version_file = { 1897 | 'content': base64.b64encode(b'\n'), 1898 | } 1899 | 1900 | getiter = { 1901 | commits_url: commits, 1902 | comments_url: comments, 1903 | } 1904 | 1905 | getitem = { 1906 | version_url: version_file, 1907 | commit_url: commit, 1908 | } 1909 | post = { 1910 | comments_url: { 1911 | 'html_url': comment_html_url, 1912 | }, 1913 | statuses_url: {}, 1914 | } 1915 | 1916 | event = _event(event_data) 1917 | 1918 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 1919 | 1920 | await router.dispatch(event, gh) 1921 | 1922 | getitem_urls = gh.getitem_urls 1923 | getiter_urls = gh.getiter_urls 1924 | post_urls = gh.post_urls 1925 | post_data = gh.post_data 1926 | patch_urls = gh.patch_urls 1927 | patch_data = gh.patch_data 1928 | 1929 | assert set(getiter_urls) == set(getiter) 1930 | assert set(getitem_urls) == set(getitem) 1931 | assert post_urls == [comments_url, statuses_url] 1932 | assert len(post_data) == 2 1933 | # Comments data 1934 | assert post_data[0].keys() == {"body"} 1935 | comment = post_data[0]["body"] 1936 | assert ":white_check_mark:" not in comment 1937 | assert ":x:" in comment 1938 | assert "error" in comment 1939 | assert "https://github.com/sympy/sympy-bot" in comment 1940 | assert "sympy/release.py" in comment 1941 | assert "There was an error getting the version" in comment 1942 | assert "https://github.com/sympy/sympy-bot/issues" in comment 1943 | for line in valid_PR_description: 1944 | assert line in comment 1945 | assert "good order" not in comment 1946 | # Statuses data 1947 | assert post_data[1] == { 1948 | "state": "error", 1949 | "target_url": comment_html_url, 1950 | "description": "The release notes check failed", 1951 | "context": "sympy-bot/release-notes", 1952 | } 1953 | assert patch_urls == [] 1954 | assert patch_data == [] 1955 | 1956 | 1957 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 1958 | @parametrize('include_extra', [True, False]) 1959 | async def test_no_user_logins_in_commits(action, include_extra): 1960 | event_data = { 1961 | 'pull_request': { 1962 | 'number': 1, 1963 | 'state': 'open', 1964 | 'merged': False, 1965 | 'comments_url': comments_url, 1966 | 'commits_url': commits_url, 1967 | 'head': { 1968 | 'user': { 1969 | 'login': 'asmeurer', 1970 | }, 1971 | }, 1972 | 'base': { 1973 | 'repo': { 1974 | 'contents_url': contents_url, 1975 | 'html_url': html_url, 1976 | }, 1977 | 'ref': 'master', 1978 | }, 1979 | 'body': valid_PR_description, 1980 | 'statuses_url': statuses_url, 1981 | }, 1982 | 'action': action, 1983 | } 1984 | 1985 | 1986 | commits = [ 1987 | { 1988 | 'author': None, 1989 | 'commit': { 1990 | 'message': "A good commit", 1991 | }, 1992 | 'sha': sha, 1993 | 'url': commit_url, 1994 | }, 1995 | ] 1996 | 1997 | commit = { 1998 | 'files': [ 1999 | { 2000 | 'status': 'modified', 2001 | }, 2002 | ], 2003 | 'parents': [ 2004 | { 2005 | "url": commit_url, 2006 | "sha": sha, 2007 | }, 2008 | ], 2009 | } 2010 | 2011 | if include_extra: 2012 | commits += [ 2013 | { 2014 | 'author': { 2015 | 'login': 'certik', 2016 | }, 2017 | 'commit': { 2018 | 'message': "A good commit", 2019 | }, 2020 | 'sha': sha, 2021 | 'url': commit_url, 2022 | }, 2023 | ] 2024 | 2025 | # No comment from sympy-bot 2026 | comments = [ 2027 | { 2028 | 'user': { 2029 | 'login': 'asmeurer', 2030 | }, 2031 | 'body': "comment", 2032 | }, 2033 | { 2034 | 'user': { 2035 | 'login': 'certik', 2036 | }, 2037 | 'body': "comment", 2038 | }, 2039 | ] 2040 | 2041 | version_file = { 2042 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 2043 | } 2044 | 2045 | getiter = { 2046 | commits_url: commits, 2047 | comments_url: comments, 2048 | } 2049 | 2050 | getitem = { 2051 | version_url: version_file, 2052 | commit_url: commit, 2053 | } 2054 | post = { 2055 | comments_url: { 2056 | 'html_url': comment_html_url, 2057 | }, 2058 | statuses_url: {}, 2059 | } 2060 | 2061 | event = _event(event_data) 2062 | 2063 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 2064 | 2065 | await router.dispatch(event, gh) 2066 | 2067 | getitem_urls = gh.getitem_urls 2068 | getiter_urls = gh.getiter_urls 2069 | post_urls = gh.post_urls 2070 | post_data = gh.post_data 2071 | patch_urls = gh.patch_urls 2072 | patch_data = gh.patch_data 2073 | 2074 | assert set(getiter_urls) == set(getiter) 2075 | assert set(getitem_urls) == set(getitem) 2076 | assert post_urls == [comments_url, statuses_url] 2077 | assert len(post_data) == 2 2078 | # Comments data 2079 | assert post_data[0].keys() == {"body"} 2080 | comment = post_data[0]["body"] 2081 | assert ":white_check_mark:" in comment 2082 | assert ":x:" not in comment 2083 | assert "new trig solvers" in comment 2084 | assert "error" not in comment 2085 | assert "https://github.com/sympy/sympy-bot" in comment 2086 | for line in valid_PR_description: 2087 | assert line in comment 2088 | assert "good order" in comment 2089 | assert "@asmeurer" in comment 2090 | if include_extra: 2091 | assert "@certik" in comment 2092 | # Statuses data 2093 | assert post_data[1] == { 2094 | "state": "success", 2095 | "target_url": comment_html_url, 2096 | "description": "The release notes look OK", 2097 | "context": "sympy-bot/release-notes", 2098 | } 2099 | assert patch_urls == [] 2100 | assert patch_data == [] 2101 | 2102 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 2103 | async def test_status_good_new_comment_other_base(action): 2104 | # Based on test_status_good_new_comment 2105 | event_data = { 2106 | 'pull_request': { 2107 | 'number': 1, 2108 | 'state': 'open', 2109 | 'merged': False, 2110 | 'comments_url': comments_url, 2111 | 'commits_url': commits_url, 2112 | 'head': { 2113 | 'user': { 2114 | 'login': 'asmeurer', 2115 | }, 2116 | }, 2117 | 'base': { 2118 | 'repo': { 2119 | 'contents_url': contents_url, 2120 | 'html_url': html_url, 2121 | }, 2122 | 'ref': '1.4', 2123 | }, 2124 | 'body': valid_PR_description, 2125 | 'statuses_url': statuses_url, 2126 | }, 2127 | 'action': action, 2128 | } 2129 | 2130 | 2131 | commits = [ 2132 | { 2133 | 'author': { 2134 | 'login': 'asmeurer', 2135 | }, 2136 | 'commit': { 2137 | 'message': "A good commit", 2138 | }, 2139 | 'sha': sha, 2140 | 'url': commit_url, 2141 | }, 2142 | { 2143 | 'author': { 2144 | 'login': 'certik', 2145 | }, 2146 | 'commit': { 2147 | 'message': "A good commit", 2148 | }, 2149 | 'sha': sha, 2150 | 'url': commit_url, 2151 | }, 2152 | # Test commits without a login 2153 | { 2154 | 'author': None, 2155 | 'commit': { 2156 | 'message': "A good commit", 2157 | }, 2158 | 'sha': sha, 2159 | 'url': commit_url, 2160 | }, 2161 | ] 2162 | 2163 | commit = { 2164 | 'files': [ 2165 | { 2166 | 'status': 'modified', 2167 | }, 2168 | ], 2169 | 'parents': [ 2170 | { 2171 | "url": commit_url, 2172 | "sha": sha, 2173 | }, 2174 | ], 2175 | } 2176 | 2177 | # No comment from sympy-bot 2178 | comments = [ 2179 | { 2180 | 'user': { 2181 | 'login': 'asmeurer', 2182 | }, 2183 | }, 2184 | { 2185 | 'user': { 2186 | 'login': 'certik', 2187 | }, 2188 | }, 2189 | ] 2190 | 2191 | version_file = { 2192 | 'content': base64.b64encode(b'__version__ = "1.4rc1"\n'), 2193 | } 2194 | 2195 | getiter = { 2196 | commits_url: commits, 2197 | comments_url: comments, 2198 | } 2199 | 2200 | getitem = { 2201 | version_url_template.format(ref='1.4'): version_file, 2202 | commit_url: commit, 2203 | } 2204 | post = { 2205 | comments_url: { 2206 | 'html_url': comment_html_url, 2207 | }, 2208 | statuses_url: {}, 2209 | } 2210 | 2211 | event = _event(event_data) 2212 | 2213 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post) 2214 | 2215 | await router.dispatch(event, gh) 2216 | 2217 | getitem_urls = gh.getitem_urls 2218 | getiter_urls = gh.getiter_urls 2219 | post_urls = gh.post_urls 2220 | post_data = gh.post_data 2221 | patch_urls = gh.patch_urls 2222 | patch_data = gh.patch_data 2223 | 2224 | assert set(getiter_urls) == set(getiter) 2225 | assert set(getitem_urls) == set(getitem) 2226 | assert post_urls == [comments_url, statuses_url] 2227 | assert len(post_data) == 2 2228 | # Comments data 2229 | assert post_data[0].keys() == {"body"} 2230 | comment = post_data[0]["body"] 2231 | assert ":white_check_mark:" in comment 2232 | assert ":x:" not in comment 2233 | assert "new trig solvers" in comment 2234 | assert "error" not in comment 2235 | assert "https://github.com/sympy/sympy-bot" in comment 2236 | assert '1.2.1' not in comment 2237 | assert '1.4' in comment 2238 | for line in valid_PR_description: 2239 | assert line in comment 2240 | assert "good order" in comment 2241 | # Statuses data 2242 | assert post_data[1] == { 2243 | "state": "success", 2244 | "target_url": comment_html_url, 2245 | "description": "The release notes look OK", 2246 | "context": "sympy-bot/release-notes", 2247 | } 2248 | assert patch_urls == [] 2249 | assert patch_data == [] 2250 | 2251 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 2252 | async def test_added_deleted_new_comment(action): 2253 | # Based on test_status_good_existing_comment 2254 | event_data = { 2255 | 'pull_request': { 2256 | 'number': 1, 2257 | 'state': 'open', 2258 | 'merged': False, 2259 | 'comments_url': comments_url, 2260 | 'commits_url': commits_url, 2261 | 'head': { 2262 | 'user': { 2263 | 'login': 'asmeurer', 2264 | }, 2265 | }, 2266 | 'base': { 2267 | 'repo': { 2268 | 'contents_url': contents_url, 2269 | 'html_url': html_url, 2270 | }, 2271 | 'ref': 'master', 2272 | }, 2273 | 'body': valid_PR_description, 2274 | 'statuses_url': statuses_url, 2275 | }, 2276 | 'action': action, 2277 | } 2278 | 2279 | sha_merge = '61697bd7249381b27a4b5d449a8061086effd381' 2280 | sha_1 = '174b8b37bc33e9eb29e710a233190d02a13bdb54' 2281 | sha_2 = 'aef484a1d46bb5389f1709d78e39126d9cb8599f' 2282 | sha_3 = sha 2283 | 2284 | commits = [ 2285 | { 2286 | 'author': { 2287 | 'login': 'asmeurer', 2288 | }, 2289 | 'commit': { 2290 | 'message': "Merge" 2291 | }, 2292 | 'sha': sha_merge, 2293 | 'url': commit_url_template.format(sha=sha_merge) 2294 | }, 2295 | { 2296 | 'author': { 2297 | 'login': 'asmeurer', 2298 | }, 2299 | 'commit': { 2300 | 'message': "Adds file1" 2301 | }, 2302 | 'sha': sha_1, 2303 | 'url': commit_url_template.format(sha=sha_1) 2304 | }, 2305 | { 2306 | 'author': { 2307 | 'login': 'asmeurer', 2308 | }, 2309 | 'commit': { 2310 | 'message': "Modifies file1", 2311 | }, 2312 | 'sha': sha_2, 2313 | 'url': commit_url_template.format(sha=sha_2), 2314 | }, 2315 | { 2316 | 'author': { 2317 | 'login': 'asmeurer', 2318 | }, 2319 | 'commit': { 2320 | 'message': "Deletes file1", 2321 | }, 2322 | 'sha': sha_3, 2323 | 'url': commit_url_template.format(sha=sha_3), 2324 | }, 2325 | ] 2326 | 2327 | commit_merge = { 2328 | 'sha': sha_1, 2329 | 'files': [ 2330 | { 2331 | 'filename': 'file1', 2332 | 'status': 'added', 2333 | }, 2334 | { 2335 | 'filename': 'file2', 2336 | 'status': 'deleted', 2337 | }, 2338 | ], 2339 | 'parents': [ 2340 | { 2341 | "url": commit_url, 2342 | "sha": sha_2, 2343 | }, 2344 | { 2345 | "url": commit_url, 2346 | "sha": sha, 2347 | }, 2348 | ], 2349 | } 2350 | 2351 | commit_add = { 2352 | 'sha': sha_1, 2353 | 'files': [ 2354 | { 2355 | 'filename': 'file1', 2356 | 'status': 'added', 2357 | }, 2358 | ], 2359 | 'parents': [ 2360 | { 2361 | "url": commit_url, 2362 | "sha": sha, 2363 | }, 2364 | ], 2365 | } 2366 | 2367 | commit_modify = { 2368 | 'sha': sha_2, 2369 | 'files': [ 2370 | { 2371 | 'filename': 'file1', 2372 | 'status': 'modified', 2373 | }, 2374 | ], 2375 | 'parents': [ 2376 | { 2377 | "url": commit_url, 2378 | "sha": sha, 2379 | }, 2380 | ], 2381 | } 2382 | 2383 | commit_delete = { 2384 | 'sha': sha_3, 2385 | 'files': [ 2386 | { 2387 | 'filename': 'file1', 2388 | 'status': 'removed', 2389 | }, 2390 | ], 2391 | 'parents': [ 2392 | { 2393 | "url": commit_url, 2394 | "sha": sha, 2395 | }, 2396 | ], 2397 | } 2398 | 2399 | comments = [ 2400 | { 2401 | 'user': { 2402 | 'login': 'sympy-bot', 2403 | }, 2404 | 'url': existing_comment_url, 2405 | 'body': release_notes_comment_body, 2406 | }, 2407 | { 2408 | 'user': { 2409 | 'login': 'asmeurer', 2410 | }, 2411 | 'body': "comment", 2412 | }, 2413 | { 2414 | 'user': { 2415 | 'login': 'certik', 2416 | }, 2417 | 'body': "comment", 2418 | }, 2419 | ] 2420 | 2421 | version_file = { 2422 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 2423 | } 2424 | 2425 | getiter = { 2426 | commits_url: commits, 2427 | comments_url: comments, 2428 | } 2429 | 2430 | getitem = { 2431 | commit_url_template.format(sha=sha_merge): commit_merge, 2432 | commit_url_template.format(sha=sha_1): commit_add, 2433 | commit_url_template.format(sha=sha_2): commit_modify, 2434 | commit_url_template.format(sha=sha_3): commit_delete, 2435 | version_url: version_file, 2436 | } 2437 | post = { 2438 | comments_url: { 2439 | 'html_url': comment_html_url, 2440 | }, 2441 | statuses_url: {}, 2442 | } 2443 | patch = { 2444 | existing_comment_url: { 2445 | 'html_url': comment_html_url, 2446 | }, 2447 | } 2448 | 2449 | event = _event(event_data) 2450 | 2451 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 2452 | 2453 | await router.dispatch(event, gh) 2454 | 2455 | getitem_urls = gh.getitem_urls 2456 | getiter_urls = gh.getiter_urls 2457 | post_urls = gh.post_urls 2458 | post_data = gh.post_data 2459 | patch_urls = gh.patch_urls 2460 | 2461 | # The rest is already tested in test_status_good_new_comment 2462 | assert set(getiter_urls) == set(getiter) 2463 | assert set(getitem_urls) == set(getitem) 2464 | assert post_urls == [statuses_url, comments_url] 2465 | assert patch_urls == [existing_comment_url] 2466 | assert len(post_data) == 2 2467 | # Comments data 2468 | assert post_data[1].keys() == {"body"} 2469 | comment = post_data[1]["body"] 2470 | assert ":white_check_mark:" not in comment 2471 | assert ":x:" not in comment 2472 | assert "\U0001f7e0" in comment 2473 | assert "error" not in comment 2474 | assert "add new files" in comment 2475 | assert "delete files" in comment 2476 | assert "https://github.com/sympy/sympy-bot" in comment 2477 | assert sha_1 in comment 2478 | assert sha_2 not in comment 2479 | assert sha_3 in comment 2480 | assert sha_merge not in comment 2481 | assert "`file1`" in comment 2482 | assert "" not in comment 2483 | assert "" not in comment 2484 | 2485 | 2486 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 2487 | async def test_added_deleted_existing_comment(action): 2488 | # Based on test_status_good_existing_comment 2489 | event_data = { 2490 | 'pull_request': { 2491 | 'number': 1, 2492 | 'state': 'open', 2493 | 'merged': False, 2494 | 'comments_url': comments_url, 2495 | 'commits_url': commits_url, 2496 | 'head': { 2497 | 'user': { 2498 | 'login': 'asmeurer', 2499 | }, 2500 | }, 2501 | 'base': { 2502 | 'repo': { 2503 | 'contents_url': contents_url, 2504 | 'html_url': html_url, 2505 | }, 2506 | 'ref': 'master', 2507 | }, 2508 | 'body': valid_PR_description, 2509 | 'statuses_url': statuses_url, 2510 | }, 2511 | 'action': action, 2512 | } 2513 | 2514 | sha_merge = '61697bd7249381b27a4b5d449a8061086effd381' 2515 | sha_1 = '174b8b37bc33e9eb29e710a233190d02a13bdb54' 2516 | sha_2 = 'aef484a1d46bb5389f1709d78e39126d9cb8599f' 2517 | sha_3 = sha 2518 | 2519 | commits = [ 2520 | { 2521 | 'author': { 2522 | 'login': 'asmeurer', 2523 | }, 2524 | 'commit': { 2525 | 'message': "Merge" 2526 | }, 2527 | 'sha': sha_merge, 2528 | 'url': commit_url_template.format(sha=sha_merge) 2529 | }, 2530 | { 2531 | 'author': { 2532 | 'login': 'asmeurer', 2533 | }, 2534 | 'commit': { 2535 | 'message': "Adds file1" 2536 | }, 2537 | 'sha': sha_1, 2538 | 'url': commit_url_template.format(sha=sha_1) 2539 | }, 2540 | { 2541 | 'author': { 2542 | 'login': 'asmeurer', 2543 | }, 2544 | 'commit': { 2545 | 'message': "Modifies file1", 2546 | }, 2547 | 'sha': sha_2, 2548 | 'url': commit_url_template.format(sha=sha_2), 2549 | }, 2550 | { 2551 | 'author': { 2552 | 'login': 'asmeurer', 2553 | }, 2554 | 'commit': { 2555 | 'message': "Deletes file1", 2556 | }, 2557 | 'sha': sha_3, 2558 | 'url': commit_url_template.format(sha=sha_3), 2559 | }, 2560 | ] 2561 | 2562 | commit_merge = { 2563 | 'sha': sha_1, 2564 | 'files': [ 2565 | { 2566 | 'filename': 'file1', 2567 | 'status': 'added', 2568 | }, 2569 | { 2570 | 'filename': 'file2', 2571 | 'status': 'deleted', 2572 | }, 2573 | ], 2574 | 'parents': [ 2575 | { 2576 | "url": commit_url, 2577 | "sha": sha_2, 2578 | }, 2579 | { 2580 | "url": commit_url, 2581 | "sha": sha, 2582 | }, 2583 | ], 2584 | } 2585 | 2586 | commit_add = { 2587 | 'sha': sha_1, 2588 | 'files': [ 2589 | { 2590 | 'filename': 'file1', 2591 | 'status': 'added', 2592 | }, 2593 | ], 2594 | 'parents': [ 2595 | { 2596 | "url": commit_url, 2597 | "sha": sha, 2598 | }, 2599 | ], 2600 | } 2601 | 2602 | commit_modify = { 2603 | 'sha': sha_2, 2604 | 'files': [ 2605 | { 2606 | 'filename': 'file1', 2607 | 'status': 'modified', 2608 | }, 2609 | ], 2610 | 'parents': [ 2611 | { 2612 | "url": commit_url, 2613 | "sha": sha, 2614 | }, 2615 | ], 2616 | } 2617 | 2618 | commit_delete = { 2619 | 'sha': sha_3, 2620 | 'files': [ 2621 | { 2622 | 'filename': 'file1', 2623 | 'status': 'removed', 2624 | }, 2625 | ], 2626 | 'parents': [ 2627 | { 2628 | "url": commit_url, 2629 | "sha": sha, 2630 | }, 2631 | ], 2632 | } 2633 | 2634 | comments = [ 2635 | { 2636 | 'user': { 2637 | 'login': 'sympy-bot', 2638 | }, 2639 | 'url': existing_comment_url, 2640 | 'body': release_notes_comment_body, 2641 | }, 2642 | { 2643 | 'user': { 2644 | 'login': 'sympy-bot', 2645 | }, 2646 | 'url': existing_added_deleted_comment_url, 2647 | 'body': added_deleted_comment_body, 2648 | }, 2649 | { 2650 | 'user': { 2651 | 'login': 'asmeurer', 2652 | }, 2653 | 'body': "comment", 2654 | }, 2655 | { 2656 | 'user': { 2657 | 'login': 'certik', 2658 | }, 2659 | 'body': "comment", 2660 | }, 2661 | ] 2662 | 2663 | version_file = { 2664 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 2665 | } 2666 | 2667 | getiter = { 2668 | commits_url: commits, 2669 | comments_url: comments, 2670 | } 2671 | 2672 | getitem = { 2673 | commit_url_template.format(sha=sha_merge): commit_merge, 2674 | commit_url_template.format(sha=sha_1): commit_add, 2675 | commit_url_template.format(sha=sha_2): commit_modify, 2676 | commit_url_template.format(sha=sha_3): commit_delete, 2677 | version_url: version_file, 2678 | } 2679 | post = { 2680 | comments_url: { 2681 | 'html_url': comment_html_url, 2682 | }, 2683 | statuses_url: {}, 2684 | } 2685 | patch = { 2686 | existing_comment_url: { 2687 | 'html_url': comment_html_url, 2688 | }, 2689 | existing_added_deleted_comment_url: { 2690 | 'html_url': comment_html_url2, 2691 | }, 2692 | } 2693 | 2694 | event = _event(event_data) 2695 | 2696 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch) 2697 | 2698 | await router.dispatch(event, gh) 2699 | 2700 | getitem_urls = gh.getitem_urls 2701 | getiter_urls = gh.getiter_urls 2702 | post_urls = gh.post_urls 2703 | post_data = gh.post_data 2704 | patch_urls = gh.patch_urls 2705 | patch_data = gh.patch_data 2706 | 2707 | assert set(getiter_urls) == set(getiter) 2708 | assert set(getitem_urls) == set(getitem) 2709 | assert post_urls == [statuses_url] 2710 | assert patch_urls == list(patch) 2711 | assert len(post_data) == 1 2712 | assert len(patch_data) == 2 2713 | # Comments data. 2714 | assert patch_data[1].keys() == {"body"} 2715 | comment = patch_data[1]["body"] 2716 | assert ":white_check_mark:" not in comment 2717 | assert ":x:" not in comment 2718 | assert "\U0001f7e0" in comment 2719 | assert "error" not in comment 2720 | assert "add new files" in comment 2721 | assert "delete files" in comment 2722 | assert "https://github.com/sympy/sympy-bot" in comment 2723 | assert sha_1 in comment 2724 | assert sha_2 not in comment 2725 | assert sha_3 in comment 2726 | assert sha_merge not in comment 2727 | assert "`file1`" in comment 2728 | assert "" not in comment 2729 | assert "" not in comment 2730 | 2731 | @parametrize('action', ['opened', 'reopened', 'synchronize', 'edited']) 2732 | async def test_added_deleted_remove_existing_comment(action): 2733 | # Based on test_status_good_existing_comment 2734 | event_data = { 2735 | 'pull_request': { 2736 | 'number': 1, 2737 | 'state': 'open', 2738 | 'merged': False, 2739 | 'comments_url': comments_url, 2740 | 'commits_url': commits_url, 2741 | 'head': { 2742 | 'user': { 2743 | 'login': 'asmeurer', 2744 | }, 2745 | }, 2746 | 'base': { 2747 | 'repo': { 2748 | 'contents_url': contents_url, 2749 | 'html_url': html_url, 2750 | }, 2751 | 'ref': 'master', 2752 | }, 2753 | 'body': valid_PR_description, 2754 | 'statuses_url': statuses_url, 2755 | }, 2756 | 'action': action, 2757 | } 2758 | 2759 | commits = [ 2760 | { 2761 | 'author': { 2762 | 'login': 'asmeurer', 2763 | }, 2764 | 'commit': { 2765 | 'message': "Modifies file1" 2766 | }, 2767 | 'sha': sha, 2768 | 'url': commit_url, 2769 | }, 2770 | ] 2771 | 2772 | commit = { 2773 | 'sha': sha, 2774 | 'files': [ 2775 | { 2776 | 'filename': 'file1', 2777 | 'status': 'modified', 2778 | }, 2779 | ], 2780 | 'parents': [ 2781 | { 2782 | "url": commit_url, 2783 | "sha": sha, 2784 | }, 2785 | ], 2786 | } 2787 | 2788 | comments = [ 2789 | { 2790 | 'user': { 2791 | 'login': 'sympy-bot', 2792 | }, 2793 | 'url': existing_comment_url, 2794 | 'body': release_notes_comment_body, 2795 | }, 2796 | { 2797 | 'user': { 2798 | 'login': 'sympy-bot', 2799 | }, 2800 | 'url': existing_added_deleted_comment_url, 2801 | 'body': added_deleted_comment_body, 2802 | }, 2803 | { 2804 | 'user': { 2805 | 'login': 'asmeurer', 2806 | }, 2807 | 'body': "comment", 2808 | }, 2809 | { 2810 | 'user': { 2811 | 'login': 'certik', 2812 | }, 2813 | 'body': "comment", 2814 | }, 2815 | ] 2816 | 2817 | version_file = { 2818 | 'content': base64.b64encode(b'__version__ = "1.2.1.dev"\n'), 2819 | } 2820 | 2821 | getiter = { 2822 | commits_url: commits, 2823 | comments_url: comments, 2824 | } 2825 | 2826 | getitem = { 2827 | commit_url: commit, 2828 | version_url: version_file, 2829 | } 2830 | post = { 2831 | comments_url: { 2832 | 'html_url': comment_html_url, 2833 | }, 2834 | statuses_url: {}, 2835 | } 2836 | patch = { 2837 | existing_comment_url: { 2838 | 'html_url': comment_html_url, 2839 | }, 2840 | } 2841 | delete = { 2842 | existing_added_deleted_comment_url: { 2843 | 'html_url': comment_html_url2, 2844 | }, 2845 | } 2846 | 2847 | event = _event(event_data) 2848 | 2849 | gh = FakeGH(getiter=getiter, getitem=getitem, post=post, patch=patch, delete=delete) 2850 | 2851 | await router.dispatch(event, gh) 2852 | 2853 | getitem_urls = gh.getitem_urls 2854 | getiter_urls = gh.getiter_urls 2855 | post_urls = gh.post_urls 2856 | post_data = gh.post_data 2857 | patch_urls = gh.patch_urls 2858 | patch_data = gh.patch_data 2859 | delete_urls = gh.delete_urls 2860 | 2861 | assert set(getiter_urls) == set(getiter) 2862 | assert set(getitem_urls) == set(getitem) 2863 | assert post_urls == [statuses_url] 2864 | assert patch_urls == list(patch) 2865 | assert delete_urls == list(delete) 2866 | assert len(post_data) == 1 2867 | assert len(patch_data) == 1 2868 | # Comments data 2869 | assert patch_data[0].keys() == {"body"} 2870 | comment = patch_data[0]["body"] 2871 | assert "release notes" in comment 2872 | assert "\U0001f7e0" not in comment 2873 | assert "add new files" not in comment 2874 | assert "delete files" not in comment 2875 | assert sha not in comment 2876 | assert "`file1`" not in comment 2877 | -------------------------------------------------------------------------------- /sympy_bot/update_wiki.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | import os 3 | import shlex 4 | import sys 5 | from subprocess import run as subprocess_run, PIPE 6 | 7 | from .changelog import update_release_notes 8 | 9 | # Modified from doctr.travis.run_command_hiding_token 10 | def run(args, shell=False, check=True): 11 | token = os.environ.get("GH_AUTH").encode('utf-8') 12 | 13 | if not shell: 14 | command = ' '.join(map(shlex.quote, args)) 15 | else: 16 | command = args 17 | 18 | command = command.replace(token.decode('utf-8'), '~'*len(token)) 19 | print(command) 20 | sys.stdout.flush() 21 | 22 | if token: 23 | stdout = stderr = PIPE 24 | else: 25 | stdout = stderr = None 26 | p = subprocess_run(args, stdout=stdout, stderr=stderr, shell=shell, check=check) 27 | if token: 28 | # XXX: Do this in a way that is streaming 29 | out, err = p.stdout, p.stderr 30 | out = out.replace(token, b"~"*len(token)) 31 | err = err.replace(token, b"~"*len(token)) 32 | if out: 33 | print(out.decode('utf-8')) 34 | if err: 35 | print(err.decode('utf-8'), file=sys.stderr) 36 | sys.stdout.flush() 37 | sys.stderr.flush() 38 | return p.returncode 39 | 40 | def update_wiki(*, wiki_url, release_notes_file, changelogs, pr_number, 41 | authors): 42 | """ 43 | Update the release notes wiki with the given release notes 44 | 45 | Assumes the token to push to the wiki is in the GH_AUTH environment variable. 46 | 47 | The git commands and their output are printed to the terminal. The token 48 | is automatically hidden from the output. 49 | 50 | Raises: 51 | 52 | - RuntimeError: If there was an error. 53 | - CalledProcessError: If there was an error with a git command 54 | 55 | Otherwise, it returns None. 56 | 57 | """ 58 | run(['git', 'config', '--global', 'user.email', "sympy+bot@sympy.org"]) 59 | run(['git', 'config', '--global', 'user.name', "SymPy Bot"]) 60 | 61 | run(['git', 'clone', wiki_url, '--depth', '1'], check=True) 62 | _, wiki = wiki_url.rsplit('/', 1) 63 | os.chdir(wiki) 64 | 65 | with open(release_notes_file, 'r') as f: 66 | rel_notes_txt = f.read() 67 | 68 | try: 69 | new_rel_notes_txt = update_release_notes(rel_notes_txt=rel_notes_txt, 70 | changelogs=changelogs, pr_number=pr_number, authors=authors) 71 | except Exception as e: 72 | raise RuntimeError(str(e)) from e 73 | 74 | with open(release_notes_file, 'w') as f: 75 | f.write(new_rel_notes_txt) 76 | 77 | run(['git', 'diff'], check=True) 78 | run(['git', 'add', release_notes_file], check=True) 79 | 80 | message = f"Update {release_notes_file} from PR #{pr_number}" 81 | run(['git', 'commit', '-m', message], check=True) 82 | 83 | parsed_url = list(urllib.parse.urlparse(wiki_url)) 84 | parsed_url[1] = os.environ.get("GH_AUTH") + '@' + parsed_url[1] 85 | auth_url = urllib.parse.urlunparse(parsed_url) 86 | 87 | # TODO: Use a deploy key to do this 88 | run(['git', 'push', auth_url, 'master'], check=True) 89 | -------------------------------------------------------------------------------- /sympy_bot/webapp.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | import datetime 3 | import os 4 | import base64 5 | from subprocess import CalledProcessError 6 | from collections import defaultdict 7 | 8 | from aiohttp import web, ClientSession 9 | 10 | from gidgethub import routing, sansio, BadRequest 11 | from gidgethub.aiohttp import GitHubAPI 12 | 13 | from .changelog import (get_changelog, update_release_notes, VERSION_RE, 14 | get_release_notes_filename, BEGIN_RELEASE_NOTES, 15 | END_RELEASE_NOTES) 16 | from .update_wiki import update_wiki 17 | 18 | router = routing.Router() 19 | 20 | USER = 'sympy-bot' 21 | RELEASE_FILE = 'sympy/release.py' 22 | 23 | async def main_post(request): 24 | # read the GitHub webhook payload 25 | body = await request.read() 26 | 27 | # our authentication token and secret 28 | secret = os.environ.get("GH_SECRET") 29 | oauth_token = os.environ.get("GH_AUTH") 30 | 31 | # a representation of GitHub webhook event 32 | event = sansio.Event.from_http(request.headers, body, secret=secret) 33 | 34 | print(f"Received {event.event} event with delivery_id={event.delivery_id}") 35 | async with ClientSession() as session: 36 | gh = GitHubAPI(session, USER, oauth_token=oauth_token, cache={}) 37 | 38 | # call the appropriate callback for the event 39 | result = await router.dispatch(event, gh) 40 | 41 | return web.Response(status=200, text=str(result)) 42 | 43 | async def main_get(request): 44 | oauth_token = os.environ.get("GH_AUTH") 45 | 46 | async with ClientSession() as session: 47 | gh = GitHubAPI(session, USER, oauth_token=oauth_token) 48 | await gh.getitem("/rate_limit") 49 | rate_limit = gh.rate_limit 50 | remaining = rate_limit.remaining 51 | total = rate_limit.limit 52 | reset_datetime = rate_limit.reset_datetime 53 | 54 | return web.Response(status=200, text=f"SymPy Bot has {remaining} of {total} GitHub API requests remaining. They will reset on {reset_datetime} (UTC), which is in {reset_datetime - datetime.datetime.now(datetime.timezone.utc)}.") 55 | 56 | @router.register("pull_request", action="opened") 57 | @router.register("pull_request", action="reopened") 58 | @router.register("pull_request", action="edited") 59 | @router.register("pull_request", action="synchronize") 60 | async def pull_request_edited(event, gh, *args, **kwargs): 61 | pr_number = event.data['pull_request']['number'] 62 | print(f"PR #{pr_number} was {event.data['action']}.") 63 | if event.data['pull_request']['state'] == "closed": 64 | print(f"PR #{pr_number} is closed, skipping") 65 | return 66 | 67 | if event.data['pull_request']['user']['login'] == "dependabot[bot]": 68 | await pull_request_noop(event, gh, status_message="This is a Dependabot PR. SymPy Bot not run.") 69 | else: 70 | await pull_request_comment_release_notes(event, gh) 71 | await pull_request_comment_added_deleted(event, gh) 72 | await rate_limit_comment(event, gh) 73 | 74 | async def pull_request_noop(event, gh, status_message=None): 75 | statuses_url = event.data['pull_request']['statuses_url'] 76 | await gh.post(statuses_url, data=dict( 77 | state='success', 78 | description=status_message, 79 | context='sympy-bot/release-notes', 80 | )) 81 | 82 | async def pull_request_comment_release_notes(event, gh): 83 | comments_url = event.data["pull_request"]["comments_url"] 84 | number = event.data["pull_request"]["number"] 85 | # TODO: Get the full list of users with commits, not just the user who 86 | # opened the PR. 87 | commits_url = event.data["pull_request"]["commits_url"] 88 | commits = gh.getiter(commits_url) 89 | users = set() 90 | header_in_message = False 91 | 92 | async for commit in commits: 93 | if commit['author']: 94 | users.add(commit['author']['login']) 95 | message = commit['commit']['message'] 96 | if BEGIN_RELEASE_NOTES in message or END_RELEASE_NOTES in message: 97 | header_in_message = commit['sha'] 98 | 99 | users.add(event.data['pull_request']['head']['user']['login']) 100 | 101 | users = sorted(users) 102 | 103 | contents_url = event.data['pull_request']['base']['repo']['contents_url'] 104 | version_url = contents_url.replace('{+path}', RELEASE_FILE) 105 | base_ref = event.data['pull_request']['base']['ref'] 106 | 107 | comments = gh.getiter(comments_url) 108 | # Try to find an existing comment to update 109 | existing_comment_release_notes = None 110 | async for comment in comments: 111 | if comment['user']['login'] == USER: 112 | if "release notes entry" in comment['body']: 113 | existing_comment_release_notes = comment 114 | 115 | status, message, changelogs = get_changelog(event.data['pull_request']['body']) 116 | 117 | if status and header_in_message: 118 | status = False 119 | message = f"* The `{BEGIN_RELEASE_NOTES}` / `{END_RELEASE_NOTES}` block should go in the pull request description only, not the commit messages. It was found in the message for the commit {header_in_message}. See https://github.com/sympy/sympy/wiki/Development-workflow#changing-of-commit-messages for information on how to edit commit messages." 120 | 121 | gh_status = 'success' if status else 'failure' 122 | 123 | release_notes_file = "!!ERROR!! Could not get the release notes filename!" 124 | if status: 125 | try: 126 | release_file = await gh.getitem(version_url + f'?ref={base_ref}') 127 | m = VERSION_RE.search(base64.b64decode(release_file['content']).decode('utf-8')) 128 | except BadRequest: # file not found 129 | m = False 130 | if not m: 131 | status = False 132 | gh_status = 'error' 133 | message = f"""\ 134 | There was an error getting the version from the `{RELEASE_FILE}` file. Please open an issue at https://github.com/sympy/sympy-bot/issues.""" 135 | else: 136 | version = m.group() 137 | release_notes_file = get_release_notes_filename(version) 138 | 139 | status_message = "The release notes look OK" if status else "The release notes check failed" 140 | 141 | emoji_status = { 142 | True: ':white_check_mark:', 143 | False: ':x:', 144 | } 145 | 146 | if status: 147 | fake_release_notes = """ 148 | ## Changes 149 | 150 | ## Authors 151 | """ 152 | release_notes_url = event.data['pull_request']['base']['repo']['html_url'] + '/wiki/' + release_notes_file[:-3] # Strip the .md for the URL 153 | 154 | try: 155 | updated_fake_release_notes = update_release_notes(rel_notes_txt=fake_release_notes, 156 | changelogs=changelogs, pr_number=number, 157 | authors=users).replace('## Authors', '').replace("## Changes", '').strip() 158 | except Exception as e: 159 | status = False 160 | status_message = "ERROR" 161 | message += f""" 162 | 163 | There was an error processing the release notes, which most likely indicates a bug in the bot. Please open an issue at https://github.com/sympy/sympy-bot/issues. The error was: {e} 164 | 165 | """ 166 | else: 167 | if changelogs: 168 | message += f'\n\nHere is what the release notes will look like:\n{updated_fake_release_notes}\n\nThis will be added to {release_notes_url}.' 169 | 170 | release_notes_message = f"""\ 171 | {emoji_status[status] if status else ''} 172 | 173 | Hi, I am the [SymPy bot](https://github.com/sympy/sympy-bot). I'm here to help you write a release notes entry. Please read the [guide on how to write release notes](https://github.com/sympy/sympy/wiki/Writing-Release-Notes). 174 | 175 | """ 176 | if not status: 177 | release_notes_message += f"{emoji_status[status]} There was an issue with the release notes. **Please do not close this pull request;** instead edit the description after reading the [guide on how to write release notes](https://github.com/sympy/sympy/wiki/Writing-Release-Notes)." 178 | 179 | release_notes_message += f""" 180 | 181 | {message} 182 | 183 |

Click here to see the pull request description that was parsed. 184 | 185 | {textwrap.indent(event.data['pull_request']['body'], ' ')} 186 | 187 |

188 | """ 189 | 190 | if existing_comment_release_notes: 191 | comment = await gh.patch(existing_comment_release_notes['url'], data={"body": release_notes_message}) 192 | else: 193 | comment = await gh.post(comments_url, data={"body": release_notes_message}) 194 | 195 | statuses_url = event.data['pull_request']['statuses_url'] 196 | await gh.post(statuses_url, data=dict( 197 | state=gh_status, 198 | target_url=comment['html_url'], 199 | description=status_message, 200 | context='sympy-bot/release-notes', 201 | )) 202 | 203 | return status, release_notes_file, changelogs, comment, users 204 | 205 | async def rate_limit_comment(event, gh): 206 | comments_url = event.data["pull_request"]["comments_url"] 207 | rate_limit = gh.rate_limit 208 | remaining = rate_limit.remaining 209 | total = rate_limit.limit 210 | reset_datetime = rate_limit.reset_datetime 211 | 212 | if remaining <= 10: 213 | message = f"""\ 214 | 215 | **:warning::warning::warning:WARNING:warning::warning::warning:**: I am nearing my API limit. I have only {remaining} of {total} API requests left. They will reset on {reset_datetime} (UTC), which is in {reset_datetime - datetime.datetime.now(datetime.timezone.utc)}. 216 | 217 | """ 218 | 219 | await gh.post(comments_url, data={"body": message}) 220 | 221 | async def pull_request_comment_added_deleted(event, gh): 222 | comments_url = event.data["pull_request"]["comments_url"] 223 | # TODO: Get the full list of users with commits, not just the user who 224 | # opened the PR. 225 | commits_url = event.data["pull_request"]["commits_url"] 226 | commits = gh.getiter(commits_url) 227 | added = defaultdict(list) 228 | deleted = defaultdict(list) 229 | 230 | async for commit in commits: 231 | # Workaround https://github.com/sympy/sympy-bot/issues/84 232 | try: 233 | com = await gh.getitem(commit['url']) 234 | except BadRequest: 235 | print(f"Warning: could not get commit {commit['sha']}") 236 | continue 237 | if len(com['parents']) > 1: 238 | # Merge commit 239 | continue 240 | for file in com['files']: 241 | if file['status'] == 'added': 242 | added[com['sha']].append(file) 243 | elif file['status'] == 'removed': 244 | deleted[com['sha']].append(file) 245 | 246 | comments = gh.getiter(comments_url) 247 | # Try to find an existing comment to update 248 | existing_comment = None 249 | # mentioned = [] 250 | async for comment in comments: 251 | if comment['user']['login'] == USER: 252 | if "add or delete" in comment['body']: 253 | existing_comment = comment 254 | break 255 | # if f'@{USER}' in comment['body']: 256 | # mentioned.append(comment) 257 | 258 | if added or deleted: 259 | # \U0001f7e0 is an orange circle. Don't include it here literally 260 | # because it causes issues in some editors. We set it as a level 3 261 | # header so it appears the same size as the GitHub :emojis:. It isn't 262 | # available as a :emoji: unfortunately. 263 | added_deleted_message = """\ 264 | ### \U0001f7e0 265 | 266 | Hi, I am the [SymPy bot](https://github.com/sympy/sympy-bot). I've noticed that some of your commits add or delete files. Since this is sometimes done unintentionally, I wanted to alert you about it. 267 | 268 | This is an experimental feature of SymPy Bot. If you have any feedback on it, please comment at https://github.com/sympy/sympy-bot/issues/75. 269 | """ 270 | if added: 271 | added_deleted_message += """ 272 | The following commits **add new files**: 273 | """ 274 | for sha, files in added.items(): 275 | added_deleted_message += f"* {sha}:\n" 276 | for file in files: 277 | added_deleted_message += f" - `{file['filename']}`\n" 278 | 279 | if deleted: 280 | added_deleted_message += """ 281 | The following commits **delete files**: 282 | """ 283 | for sha, files in deleted.items(): 284 | added_deleted_message += f"* {sha}:\n" 285 | for file in files: 286 | added_deleted_message += f" - `{file['filename']}`\n" 287 | 288 | added_deleted_message += """ 289 | If these files were added/deleted on purpose, you can ignore this message. 290 | """ 291 | # TODO: Allow users to whitelist files by @mentioning the bot. Then we 292 | # could make this give a failing status. 293 | 294 | if existing_comment: 295 | comment = await gh.patch(existing_comment['url'], data={"body": added_deleted_message}) 296 | else: 297 | comment = await gh.post(comments_url, data={"body": added_deleted_message}) 298 | elif existing_comment: 299 | # Files were added or deleted before but now they aren't, so delete 300 | # the comment 301 | await gh.delete(existing_comment['url']) 302 | 303 | @router.register("pull_request", action="closed") 304 | async def pull_request_closed(event, gh, *args, **kwargs): 305 | pr_number = event.data['pull_request']['number'] 306 | print(f"PR #{pr_number} was {event.data['action']}.") 307 | if not event.data['pull_request']['merged']: 308 | print(f"PR #{pr_number} was closed without merging, skipping") 309 | return 310 | 311 | status, release_notes_file, changelogs, comment, users = await pull_request_comment_release_notes(event, gh, *args, **kwargs) 312 | 313 | wiki_url = event.data['pull_request']['base']['repo']['html_url'] + '.wiki' 314 | release_notes_url = event.data['pull_request']['base']['repo']['html_url'] + '/wiki/' + release_notes_file[:-3] # Strip the .md for the URL 315 | 316 | number = event.data["pull_request"]["number"] 317 | 318 | if status: 319 | if changelogs: 320 | try: 321 | update_wiki( 322 | wiki_url=wiki_url, 323 | release_notes_file=release_notes_file, 324 | changelogs=changelogs, 325 | pr_number=number, 326 | authors=users, 327 | ) 328 | update_message = comment['body'] + f""" 329 | 330 | **Update** 331 | 332 | The release notes on the [wiki]({release_notes_url}) have been updated. 333 | """ 334 | comment = await gh.patch(comment['url'], data={"body": update_message}) 335 | except RuntimeError as e: 336 | await error_comment(event, gh, e.args[0]) 337 | raise 338 | except CalledProcessError as e: 339 | await error_comment(event, gh, str(e)) 340 | raise 341 | else: 342 | print(f"PR #{pr_number} was merged with no change log entries.") 343 | else: 344 | message = "The pull request was merged even though the release notes bot had a failing status." 345 | await error_comment(event, gh, message) 346 | 347 | await rate_limit_comment(event, gh) 348 | 349 | async def error_comment(event, gh, message): 350 | """ 351 | Add a new comment with an error message. For use when updating the release 352 | notes fails. 353 | """ 354 | token = os.environ.get("GH_AUTH") 355 | message = message.replace(token, '~~~TOKEN~~~') 356 | 357 | error_message = f"""\ 358 | 359 | **:rotating_light::rotating_light::rotating_light:ERROR:rotating_light::rotating_light::rotating_light:** There was an error automatically updating the release notes. Normally it should not have been possible to merge this pull request. You might want to open an issue about this at https://github.com/sympy/sympy-bot/issues. 360 | 361 | In the meantime, you will need to update the release notes on the wiki manually. 362 | 363 | The error message was: {message} 364 | """ 365 | 366 | url = event.data["pull_request"]["comments_url"] 367 | comment = await gh.post(url, data={"body": error_message}) 368 | 369 | statuses_url = event.data['pull_request']['statuses_url'] 370 | await gh.post(statuses_url, data=dict( 371 | state='error', 372 | target_url=comment['html_url'], 373 | description='There was an error updating the release notes on the wiki.', 374 | context='sympy-bot/release-notes', 375 | )) 376 | --------------------------------------------------------------------------------