├── .editorconfig ├── .gitignore ├── .pyup.yml ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── changelogs ├── __init__.py ├── changelogs.py ├── cli.py ├── custom │ ├── __init__.py │ └── pypi │ │ ├── __init__.py │ │ ├── alabaster.py │ │ ├── alembic.py │ │ ├── beautifulsoup4.py │ │ ├── boto.py │ │ ├── cffi.py │ │ ├── cheroot.py │ │ ├── django.py │ │ ├── django_braces.py │ │ ├── django_coverage_plugin.py │ │ ├── django_haystack.py │ │ ├── django_storages_redux.py │ │ ├── djangorestframework.py │ │ ├── docutils.py │ │ ├── factory_boy.py │ │ ├── flake8.py │ │ ├── genshi.py │ │ ├── graphene.py │ │ ├── gunicorn.py │ │ ├── imapclient.py │ │ ├── jinja2.py │ │ ├── lazy-object-proxy.py │ │ ├── libsass.py │ │ ├── mako.py │ │ ├── map.txt │ │ ├── mccabe.py │ │ ├── mysqlclient.py │ │ ├── newrelic.py │ │ ├── pandas.py │ │ ├── pbr.py │ │ ├── pep8_naming.py │ │ ├── py.py │ │ ├── py_trello.py │ │ ├── pyaudio.py │ │ ├── pyinotify.py │ │ ├── python_ldap.py │ │ ├── pytz.py │ │ ├── pyyaml.py │ │ ├── redis.py │ │ ├── robozilla.py │ │ ├── selenium.py │ │ ├── six.py │ │ ├── sphinx_rtd_theme.py │ │ ├── sqlalchemy.py │ │ ├── synapse.py │ │ ├── twine.py │ │ ├── uwsgi.py │ │ ├── websocket_client.py │ │ └── whitenoise.py ├── finder.py ├── launchpad.py ├── npm.py ├── parser.py ├── pypi.py └── rubygems.py ├── docs ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_changelogs.py ├── test_commit_logs.py ├── test_finder.py ├── test_parser.py └── test_pypi.py ├── tox.ini └── vcr └── cassettes ├── tests.test_changelogs.test_115wangpan.json ├── tests.test_changelogs.test_17monip.json ├── tests.test_changelogs.test_1pass.json ├── tests.test_changelogs.test_1to001.json ├── tests.test_changelogs.test_3to2.json ├── tests.test_changelogs.test_3xsd.json ├── tests.test_changelogs.test_40wt_common_tasks.json ├── tests.test_changelogs.test_a2svm.json ├── tests.test_changelogs.test_aacgmv2.json ├── tests.test_changelogs.test_aadict.json ├── tests.test_changelogs.test_aartfaac_arthur.json ├── tests.test_changelogs.test_abbyy.json ├── tests.test_changelogs.test_abclinuxuapi.json ├── tests.test_changelogs.test_abcpmc.json ├── tests.test_changelogs.test_abe.json ├── tests.test_changelogs.test_abilian_core.json ├── tests.test_changelogs.test_abilian_sbe.json ├── tests.test_changelogs.test_ablog_api.json ├── tests.test_changelogs.test_ablog_cli.json ├── tests.test_changelogs.test_abraxas.json ├── tests.test_changelogs.test_abydos.json ├── tests.test_changelogs.test_accept.json ├── tests.test_changelogs.test_accloudtant.json ├── tests.test_changelogs.test_acdcli.json ├── tests.test_changelogs.test_ace.json ├── tests.test_changelogs.test_ach.json ├── tests.test_changelogs.test_acidfile.json ├── tests.test_changelogs.test_acli.json ├── tests.test_changelogs.test_acme_mgmtserver.json ├── tests.test_changelogs.test_acorn.json ├── tests.test_changelogs.test_acp_calendar.json ├── tests.test_changelogs.test_acquisition.json ├── tests.test_changelogs.test_acrylamid.json ├── tests.test_changelogs.test_acsone_recipe_odoo_pydev.json ├── tests.test_changelogs.test_activecampaign.json ├── tests.test_changelogs.test_activity_feed.json ├── tests.test_changelogs.test_activity_monitor.json ├── tests.test_changelogs.test_activityio.json ├── tests.test_changelogs.test_aio_manager.json ├── tests.test_changelogs.test_aio_periodic.json ├── tests.test_changelogs.test_aio_pybars.json ├── tests.test_changelogs.test_aio_yamlconfig.json ├── tests.test_changelogs.test_aioamqp.json ├── tests.test_changelogs.test_aiobotocore.json ├── tests.test_changelogs.test_aiobotocore_mirror.json ├── tests.test_changelogs.test_aiocoap.json ├── tests.test_changelogs.test_aiocouchdb.json ├── tests.test_changelogs.test_aiocron.json ├── tests.test_changelogs.test_aiodjango.json ├── tests.test_changelogs.test_aiodns.json ├── tests.test_changelogs.test_aioes.json ├── tests.test_changelogs.test_aioeventlet.json ├── tests.test_changelogs.test_aioftp.json ├── tests.test_changelogs.test_aiogibson.json ├── tests.test_changelogs.test_aioh2.json ├── tests.test_changelogs.test_aiohdfs.json ├── tests.test_changelogs.test_aiohttp.json ├── tests.test_changelogs.test_alabaster.json ├── tests.test_changelogs.test_alembic.json ├── tests.test_changelogs.test_alembic_verify.json ├── tests.test_changelogs.test_alertlogic.json ├── tests.test_changelogs.test_alfajor.json ├── tests.test_changelogs.test_algebraixlib.json ├── tests.test_changelogs.test_algoliasearch.json ├── tests.test_changelogs.test_algoliasearch_django.json ├── tests.test_changelogs.test_algoliasearchasync.json ├── tests.test_changelogs.test_alignment.json ├── tests.test_changelogs.test_alipay.json ├── tests.test_changelogs.test_alkey.json ├── tests.test_changelogs.test_allanon.json ├── tests.test_changelogs.test_allauth_watchdog_id.json ├── tests.test_changelogs.test_allensdk.json ├── tests.test_changelogs.test_allmychanges.json ├── tests.test_changelogs.test_allocine_wrapper.json ├── tests.test_changelogs.test_alm_solrindex.json ├── tests.test_changelogs.test_almond.json ├── tests.test_changelogs.test_alnair.json ├── tests.test_changelogs.test_aloe.json ├── tests.test_changelogs.test_aloe_django.json ├── tests.test_changelogs.test_alogator.json ├── tests.test_changelogs.test_aloisius.json ├── tests.test_changelogs.test_alot.json ├── tests.test_changelogs.test_alotofeffort.json ├── tests.test_changelogs.test_alp_proj.json ├── tests.test_changelogs.test_altair.json ├── tests.test_changelogs.test_altapay.json ├── tests.test_changelogs.test_altered_states.json ├── tests.test_changelogs.test_altgraph.json ├── tests.test_changelogs.test_alto.json ├── tests.test_changelogs.test_amadeus.json ├── tests.test_changelogs.test_amfm_decompy.json ├── tests.test_changelogs.test_ami_push.json ├── tests.test_changelogs.test_amico.json ├── tests.test_changelogs.test_amifinder.json ├── tests.test_changelogs.test_amitu_hstore.json ├── tests.test_changelogs.test_amitu_websocket_client.json ├── tests.test_changelogs.test_amitu_zutils.json ├── tests.test_changelogs.test_amo2kinto.json ├── tests.test_changelogs.test_amp.json ├── tests.test_changelogs.test_amphora.json ├── tests.test_changelogs.test_amplecode.json ├── tests.test_changelogs.test_amplecode_recipe_template.json ├── tests.test_changelogs.test_amqp.json ├── tests.test_changelogs.test_amqp_dispatcher.json ├── tests.test_changelogs.test_amqp_storm.json ├── tests.test_changelogs.test_amqpeek.json ├── tests.test_changelogs.test_amqplib.json ├── tests.test_changelogs.test_amqpstorm.json ├── tests.test_changelogs.test_amt.json ├── tests.test_changelogs.test_argparse.json ├── tests.test_changelogs.test_att_iot_client.json ├── tests.test_changelogs.test_babel.json ├── tests.test_changelogs.test_beautifulsoup4.json ├── tests.test_changelogs.test_boto.json ├── tests.test_changelogs.test_brotli.json ├── tests.test_changelogs.test_browsercookiejar.json ├── tests.test_changelogs.test_browsermob_proxy.json ├── tests.test_changelogs.test_browserstacker.json ├── tests.test_changelogs.test_brubeck.json ├── tests.test_changelogs.test_bruges.json ├── tests.test_changelogs.test_brush.json ├── tests.test_changelogs.test_bsdconv.json ├── tests.test_changelogs.test_bsdiff4.json ├── tests.test_changelogs.test_bsdploy.json ├── tests.test_changelogs.test_bson_lazy.json ├── tests.test_changelogs.test_bsonrpc.json ├── tests.test_changelogs.test_bst_pygasus_core.json ├── tests.test_changelogs.test_bst_pygasus_datamanager.json ├── tests.test_changelogs.test_bst_pygasus_demo.json ├── tests.test_changelogs.test_bst_pygasus_i18n.json ├── tests.test_changelogs.test_bst_pygasus_resources.json ├── tests.test_changelogs.test_bst_pygasus_security.json ├── tests.test_changelogs.test_bst_pygasus_session.json ├── tests.test_changelogs.test_bst_pygasus_wsgi.json ├── tests.test_changelogs.test_btcndash.json ├── tests.test_changelogs.test_btnamespace.json ├── tests.test_changelogs.test_bts_proxy.json ├── tests.test_changelogs.test_bts_tools.json ├── tests.test_changelogs.test_btx.json ├── tests.test_changelogs.test_bubbles.json ├── tests.test_changelogs.test_buccaneer.json ├── tests.test_changelogs.test_bucho.json ├── tests.test_changelogs.test_buck_pprint.json ├── tests.test_changelogs.test_bucky.json ├── tests.test_changelogs.test_buffalofq.json ├── tests.test_changelogs.test_bufferkdtree.json ├── tests.test_changelogs.test_bugsy.json ├── tests.test_changelogs.test_bugwarrior.json ├── tests.test_changelogs.test_bugzilla2fedmsg.json ├── tests.test_changelogs.test_bugzillatools.json ├── tests.test_changelogs.test_build_commands.json ├── tests.test_changelogs.test_buildbot_travis.json ├── tests.test_changelogs.test_buildchecker.json ├── tests.test_changelogs.test_buildfox.json ├── tests.test_changelogs.test_buildout_autoextras.json ├── tests.test_changelogs.test_buildout_disablessl.json ├── tests.test_changelogs.test_buildout_dumppickedversions2.json ├── tests.test_changelogs.test_buildout_eggscleaner.json ├── tests.test_changelogs.test_buildout_gc.json ├── tests.test_changelogs.test_buildout_helpers.json ├── tests.test_changelogs.test_buildout_minitagificator.json ├── tests.test_changelogs.test_buildout_packagename.json ├── tests.test_changelogs.test_buildout_script.json ├── tests.test_changelogs.test_bundler.json ├── tests.test_changelogs.test_cffi.json ├── tests.test_changelogs.test_cheroot.json ├── tests.test_changelogs.test_dateutil.json ├── tests.test_changelogs.test_dj_dashboard.json ├── tests.test_changelogs.test_django.json ├── tests.test_changelogs.test_django_braces.json ├── tests.test_changelogs.test_django_countries.json ├── tests.test_changelogs.test_django_coverage_plugin.json ├── tests.test_changelogs.test_django_fernet_fields.json ├── tests.test_changelogs.test_django_filebrowser_no_grappelli_demencia.json ├── tests.test_changelogs.test_django_jinja.json ├── tests.test_changelogs.test_django_registration_redux.json ├── tests.test_changelogs.test_django_statici18n.json ├── tests.test_changelogs.test_django_storages_redux.json ├── tests.test_changelogs.test_django_strategy_field.json ├── tests.test_changelogs.test_django_stw.json ├── tests.test_changelogs.test_django_su.json ├── tests.test_changelogs.test_django_sub_query.json ├── tests.test_changelogs.test_django_subcommand.json ├── tests.test_changelogs.test_django_subcommand2.json ├── tests.test_changelogs.test_django_subdomain_instances.json ├── tests.test_changelogs.test_django_subs.json ├── tests.test_changelogs.test_django_subscribe.json ├── tests.test_changelogs.test_django_suit.json ├── tests.test_changelogs.test_django_suit_dashboard.json ├── tests.test_changelogs.test_django_suit_locale.json ├── tests.test_changelogs.test_django_suit_rq.json ├── tests.test_changelogs.test_django_suit_sortable.json ├── tests.test_changelogs.test_django_summernote.json ├── tests.test_changelogs.test_django_sunset.json ├── tests.test_changelogs.test_django_superform.json ├── tests.test_changelogs.test_django_supervisor.json ├── tests.test_changelogs.test_django_support_tickets.json ├── tests.test_changelogs.test_django_toolkit.json ├── tests.test_changelogs.test_djangorestframework.json ├── tests.test_changelogs.test_djangovisor.json ├── tests.test_changelogs.test_docutils.json ├── tests.test_changelogs.test_experimental_noacquisition.json ├── tests.test_changelogs.test_factory_boy.json ├── tests.test_changelogs.test_fake_factory.json ├── tests.test_changelogs.test_ff_find.json ├── tests.test_changelogs.test_flake8.json ├── tests.test_changelogs.test_foolscap.json ├── tests.test_changelogs.test_fs_extra.json ├── tests.test_changelogs.test_gbptestheat.json ├── tests.test_changelogs.test_genshi.json ├── tests.test_changelogs.test_get_max_chars.json ├── tests.test_changelogs.test_graphene.json ├── tests.test_changelogs.test_graphql.json ├── tests.test_changelogs.test_gunicorn.json ├── tests.test_changelogs.test_gwrappy.json ├── tests.test_changelogs.test_gxformat2.json ├── tests.test_changelogs.test_gyroid.json ├── tests.test_changelogs.test_gzbus.json ├── tests.test_changelogs.test_gzip_reader.json ├── tests.test_changelogs.test_h2o.json ├── tests.test_changelogs.test_h2o_pysparkling_1_6.json ├── tests.test_changelogs.test_h2o_pysparkling_2_0.json ├── tests.test_changelogs.test_h5cube.json ├── tests.test_changelogs.test_haas.json ├── tests.test_changelogs.test_habanero.json ├── tests.test_changelogs.test_habitat.json ├── tests.test_changelogs.test_hac.json ├── tests.test_changelogs.test_hachi.json ├── tests.test_changelogs.test_hack.json ├── tests.test_changelogs.test_hacker.json ├── tests.test_changelogs.test_hackernews.json ├── tests.test_changelogs.test_hackernews_python.json ├── tests.test_changelogs.test_hackertray.json ├── tests.test_changelogs.test_haystack.json ├── tests.test_changelogs.test_htmllib.json ├── tests.test_changelogs.test_imapclient.json ├── tests.test_changelogs.test_ipaddr.json ├── tests.test_changelogs.test_jinja2.json ├── tests.test_changelogs.test_json2.json ├── tests.test_changelogs.test_kinto_fxa.json ├── tests.test_changelogs.test_kinto_http.json ├── tests.test_changelogs.test_kinto_ldap.json ├── tests.test_changelogs.test_kinto_pusher.json ├── tests.test_changelogs.test_kinto_redis.json ├── tests.test_changelogs.test_kinto_wizard.json ├── tests.test_changelogs.test_kipart.json ├── tests.test_changelogs.test_kissanime_dl.json ├── tests.test_changelogs.test_kit.json ├── tests.test_changelogs.test_kitchen.json ├── tests.test_changelogs.test_kitchensink.json ├── tests.test_changelogs.test_kitty_fuzzer.json ├── tests.test_changelogs.test_kittyfuzzer.json ├── tests.test_changelogs.test_kivy_okapi.json ├── tests.test_changelogs.test_klaus.json ├── tests.test_changelogs.test_klein.json ├── tests.test_changelogs.test_kliko.json ├── tests.test_changelogs.test_launchpad_authres.json ├── tests.test_changelogs.test_launchpad_dkimpy.json ├── tests.test_changelogs.test_launchpad_not_existent.json ├── tests.test_changelogs.test_lazy_object_proxy.json ├── tests.test_changelogs.test_ldap3.json ├── tests.test_changelogs.test_libsass.json ├── tests.test_changelogs.test_mako.json ├── tests.test_changelogs.test_matrix_angular_sdk.json ├── tests.test_changelogs.test_mayavi.json ├── tests.test_changelogs.test_mccabe.json ├── tests.test_changelogs.test_mezzaninefor1_7.json ├── tests.test_changelogs.test_mrwolfe.json ├── tests.test_changelogs.test_msaf.json ├── tests.test_changelogs.test_mschematool.json ├── tests.test_changelogs.test_msd.json ├── tests.test_changelogs.test_msgflo.json ├── tests.test_changelogs.test_msgpack_numpy.json ├── tests.test_changelogs.test_msgpack_python.json ├── tests.test_changelogs.test_msisdn_cli.json ├── tests.test_changelogs.test_msmbuilder.json ├── tests.test_changelogs.test_mss.json ├── tests.test_changelogs.test_mssqlcli.json ├── tests.test_changelogs.test_mstranslator.json ├── tests.test_changelogs.test_mtb.json ├── tests.test_changelogs.test_mtj_f3u1.json ├── tests.test_changelogs.test_mtj_jibber.json ├── tests.test_changelogs.test_mtools.json ├── tests.test_changelogs.test_mts.json ├── tests.test_changelogs.test_mucloud.json ├── tests.test_changelogs.test_muda.json ├── tests.test_changelogs.test_mysqlclient.json ├── tests.test_changelogs.test_newrelic.json ├── tests.test_changelogs.test_numpy.json ├── tests.test_changelogs.test_openpyxl.json ├── tests.test_changelogs.test_pandas.json ├── tests.test_changelogs.test_pbr.json ├── tests.test_changelogs.test_pep8_naming.json ├── tests.test_changelogs.test_pillow.json ├── tests.test_changelogs.test_planetary_test_data.json ├── tests.test_changelogs.test_planetaryimage.json ├── tests.test_changelogs.test_planetpy.json ├── tests.test_changelogs.test_plank.json ├── tests.test_changelogs.test_planterbox.json ├── tests.test_changelogs.test_planterbox_webdriver.json ├── tests.test_changelogs.test_plantextract.json ├── tests.test_changelogs.test_plaster.json ├── tests.test_changelogs.test_platocdp_newsportlet.json ├── tests.test_changelogs.test_platocdp_timesheet.json ├── tests.test_changelogs.test_platter.json ├── tests.test_changelogs.test_play_scraper.json ├── tests.test_changelogs.test_playdeliver.json ├── tests.test_changelogs.test_player.json ├── tests.test_changelogs.test_playerdo.json ├── tests.test_changelogs.test_playerpiano.json ├── tests.test_changelogs.test_playitagainsam.json ├── tests.test_changelogs.test_playsound.json ├── tests.test_changelogs.test_plecost.json ├── tests.test_changelogs.test_promise.json ├── tests.test_changelogs.test_py.json ├── tests.test_changelogs.test_py_trello.json ├── tests.test_changelogs.test_pyaudio.json ├── tests.test_changelogs.test_pyinotify.json ├── tests.test_changelogs.test_pyladies.json ├── tests.test_changelogs.test_pylama.json ├── tests.test_changelogs.test_pylangacq.json ├── tests.test_changelogs.test_pylapjv.json ├── tests.test_changelogs.test_pylastfm.json ├── tests.test_changelogs.test_pyldap.json ├── tests.test_changelogs.test_pyldavis.json ├── tests.test_changelogs.test_pyleri.json ├── tests.test_changelogs.test_pyli.json ├── tests.test_changelogs.test_pylibacl.json ├── tests.test_changelogs.test_pylibdmtx.json ├── tests.test_changelogs.test_pylibfreenect2.json ├── tests.test_changelogs.test_pylibftdi.json ├── tests.test_changelogs.test_pyliblinear.json ├── tests.test_changelogs.test_pyliblo.json ├── tests.test_changelogs.test_pylibmc.json ├── tests.test_changelogs.test_pylibrabbitmq.json ├── tests.test_changelogs.test_pylibsass.json ├── tests.test_changelogs.test_pylibscrypt.json ├── tests.test_changelogs.test_pyotp.json ├── tests.test_changelogs.test_pyparsing.json ├── tests.test_changelogs.test_pysandbox.json ├── tests.test_changelogs.test_python_amazon_product_api.json ├── tests.test_changelogs.test_python_ldap.json ├── tests.test_changelogs.test_pywsman.json ├── tests.test_changelogs.test_pyyaml.json ├── tests.test_changelogs.test_pyzmq_static.json ├── tests.test_changelogs.test_qiita.json ├── tests.test_changelogs.test_qiita_spots.json ├── tests.test_changelogs.test_qiniu.json ├── tests.test_changelogs.test_qiniu_cli.json ├── tests.test_changelogs.test_qipipe.json ├── tests.test_changelogs.test_qiprofile_rest_client.json ├── tests.test_changelogs.test_qiutil.json ├── tests.test_changelogs.test_qixnat.json ├── tests.test_changelogs.test_qllauncher.json ├── tests.test_changelogs.test_qlutils.json ├── tests.test_changelogs.test_qmenuview.json ├── tests.test_changelogs.test_qonda.json ├── tests.test_changelogs.test_qopen.json ├── tests.test_changelogs.test_qpack.json ├── tests.test_changelogs.test_qpic.json ├── tests.test_changelogs.test_qrcode.json ├── tests.test_changelogs.test_qstk.json ├── tests.test_changelogs.test_qstring.json ├── tests.test_changelogs.test_qt_binder.json ├── tests.test_changelogs.test_redis.json ├── tests.test_changelogs.test_requesocks.json ├── tests.test_changelogs.test_requests.json ├── tests.test_changelogs.test_robozilla.json ├── tests.test_changelogs.test_sandboxlib.json ├── tests.test_changelogs.test_selenium.json ├── tests.test_changelogs.test_sentry_sdk.json ├── tests.test_changelogs.test_sijax.json ├── tests.test_changelogs.test_silentdune_client.json ├── tests.test_changelogs.test_silex.json ├── tests.test_changelogs.test_silly_content_generator.json ├── tests.test_changelogs.test_silp.json ├── tests.test_changelogs.test_silva_app_document.json ├── tests.test_changelogs.test_silva_app_forest.json ├── tests.test_changelogs.test_silva_app_mediacontent.json ├── tests.test_changelogs.test_silva_app_news.json ├── tests.test_changelogs.test_silva_app_page.json ├── tests.test_changelogs.test_silva_app_photogallery.json ├── tests.test_changelogs.test_silva_app_redirectlink.json ├── tests.test_changelogs.test_silva_app_shorturl.json ├── tests.test_changelogs.test_silva_app_sitemap.json ├── tests.test_changelogs.test_silva_app_subscriptions.json ├── tests.test_changelogs.test_silva_batch.json ├── tests.test_changelogs.test_silva_captcha.json ├── tests.test_changelogs.test_silva_core_cache.json ├── tests.test_changelogs.test_silva_core_conf.json ├── tests.test_changelogs.test_simplejson.json ├── tests.test_changelogs.test_six.json ├── tests.test_changelogs.test_slc_facetedcalendar.json ├── tests.test_changelogs.test_spacesocket.json ├── tests.test_changelogs.test_sphinx_html5_basic_theme.json ├── tests.test_changelogs.test_sphinx_rtd_theme.json ├── tests.test_changelogs.test_sqlalchemy.json ├── tests.test_changelogs.test_sqlalchemy_case_insesitive.json ├── tests.test_changelogs.test_synapse.json ├── tests.test_changelogs.test_ticketus.json ├── tests.test_changelogs.test_tickeys.json ├── tests.test_changelogs.test_ticktock.json ├── tests.test_changelogs.test_tictactoexxl.json ├── tests.test_changelogs.test_tiddlyweb.json ├── tests.test_changelogs.test_tidehunter.json ├── tests.test_changelogs.test_tif2geojson.json ├── tests.test_changelogs.test_tiffcapture.json ├── tests.test_changelogs.test_tifffile.json ├── tests.test_changelogs.test_tikapy.json ├── tests.test_changelogs.test_tikz2pdf.json ├── tests.test_changelogs.test_tilde.json ├── tests.test_changelogs.test_tilecloud_chain.json ├── tests.test_changelogs.test_tilematrix.json ├── tests.test_changelogs.test_tilequeue.json ├── tests.test_changelogs.test_tilestache.json ├── tests.test_changelogs.test_time2relax.json ├── tests.test_changelogs.test_timecode.json ├── tests.test_changelogs.test_timecodes.json ├── tests.test_changelogs.test_tox.json ├── tests.test_changelogs.test_twine.json ├── tests.test_changelogs.test_uwsgi.json ├── tests.test_changelogs.test_websocket_client.json ├── tests.test_changelogs.test_whitenoise.json ├── tests.test_changelogs.test_xlsx2csv.json ├── tests.test_changelogs.test_xlsx_streaming.json ├── tests.test_changelogs.test_xlsxwriter.json ├── tests.test_changelogs.test_xlsxwriterchan.json ├── tests.test_changelogs.test_xlutils.json ├── tests.test_changelogs.test_xm_charting.json ├── tests.test_changelogs.test_xm_theme.json ├── tests.test_changelogs.test_xman.json ├── tests.test_changelogs.test_xmldataset.json ├── tests.test_changelogs.test_xmlenc.json ├── tests.test_changelogs.test_xmlformatter.json ├── tests.test_changelogs.test_xmljson.json ├── tests.test_changelogs.test_xmlpylighter.json ├── tests.test_changelogs.test_xmlr.json ├── tests.test_changelogs.test_xmlrpclibex.json ├── tests.test_changelogs.test_xmlrpcssl.json ├── tests.test_changelogs.test_xmlstats_py.json ├── tests.test_changelogs.test_xmltag.json ├── tests.test_changelogs.test_xmltodict.json ├── tests.test_changelogs.test_zodbtools.json └── tests.test_commit_logs.test_changelogs.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # pyenv python configuration file 62 | .python-version 63 | sandbox/ 64 | 65 | ### JetBrains template 66 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 67 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 68 | 69 | # User-specific stuff: 70 | .idea/workspace.xml 71 | .idea/tasks.xml 72 | .idea/dictionaries 73 | .idea/vcs.xml 74 | .idea/jsLibraryMappings.xml 75 | 76 | # Sensitive or high-churn files: 77 | .idea/dataSources.ids 78 | .idea/dataSources.xml 79 | .idea/dataSources.local.xml 80 | .idea/sqlDataSources.xml 81 | .idea/dynamic.xml 82 | .idea/uiDesigner.xml 83 | 84 | # Gradle: 85 | .idea/gradle.xml 86 | .idea/libraries 87 | 88 | # Mongo Explorer plugin: 89 | .idea/mongoSettings.xml 90 | 91 | ## File-based project format: 92 | *.iws 93 | 94 | ## Plugin-specific files: 95 | 96 | # IntelliJ 97 | /out/ 98 | 99 | # mpeltonen/sbt-idea plugin 100 | .idea_modules/ 101 | 102 | # JIRA plugin 103 | atlassian-ide-plugin.xml 104 | 105 | # Crashlytics plugin (for Android Studio and IntelliJ) 106 | com_crashlytics_export_strings.xml 107 | crashlytics.properties 108 | crashlytics-build.properties 109 | fabric.properties 110 | 111 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | update: insecure 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | only: 3 | - master 4 | - "/^\\d+\\.\\d+(\\.\\d+)?(-\\S*)?$/" 5 | deploy: 6 | user: rafaelpivato 7 | provider: pypi 8 | password: 9 | secure: D7gJpjQMx0KEYZCdlmLzjLGy4fB67xZ7Vd74AdePQua9qmzsMLxLl6O8iF11dm5/9oTS3vuUtxAetV/MUK1msEO2mvnJddujMJ9VpeFwkv99i9Ua1PEBAlW732CyWa7zDWjd4P+/MU/Gm5rQPYLbwaYpy7UAXzFpCeRtjafPxY6/dlmSEttVImOuVRIOswgww+Q08dlEnim0SY6TH4OsWf7eEuFVOYW1G+iLl8mg6yt/YaSbyl9lWt8h0B3twJ8v86vSzxVSUC07d8Rd0WO+jYC00qX6xx0tZK+sCvVgid4UW/G8r6dYzLbaOdPQTUvbj+n7TvXuMZpBESrJj8d/pfd6W1QbfylfOF3as/0u2JYTtfbwYNqBTIWOMHnenpl/6kb+dC5zynpnrViIgS4PincaLgPjBqHveBXdOPU2WqhjAj6Aj2swB+XtopiYopD3Wpl7foFhF67C1pFHk3lZMO7c2y8RBT+RgcNoUJp23qONDCalR4dJDwTThfs44EDuji1kbEmwnzMcpvQj/NJgaiDfSnvq06pUUCEdkm1KF31NAegGHaxW44UvRZu8y6npozD15QqPd44wUez1Vm8aO+lb/VXGIbB7zI8eEvv40YvRkkfozgjVa9aU7VYDAO4lOwpDYAA8CF1BMrSHrlA5PMxNUyBaoBeH7Q+6izr1Pmg= 10 | distributions: sdist bdist_wheel 11 | on: 12 | repo: pyupio/changelogs 13 | tags: true 14 | python: '3.7' 15 | language: python 16 | python: 17 | - '3.6' 18 | - '3.7' 19 | - '3.8' 20 | - '3.9' 21 | install: 22 | - pip install codecov flake8 tox-travis 23 | script: 24 | - tox 25 | after_success: 26 | - codecov 27 | - flake8 28 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Jannis Gebauer 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/pyupio/changelogs/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | and "help wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | changelogs could always use more documentation, whether as part of the 42 | official changelogs docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/pyupio/changelogs/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `changelogs` for local development. 61 | 62 | 1. Fork the `changelogs` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/changelogs.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv changelogs 70 | $ cd changelogs/ 71 | $ pip install -e .[dev] 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 82 | $ tox 83 | 84 | 6. Commit your changes and push your branch to GitHub:: 85 | 86 | $ git add . 87 | $ git commit -m "Your detailed description of your changes." 88 | $ git push origin name-of-your-bugfix-or-feature 89 | 90 | 7. Submit a pull request through the GitHub website. 91 | 92 | Pull Request Guidelines 93 | ----------------------- 94 | 95 | Before you submit a pull request, check that it meets these guidelines: 96 | 97 | 1. The pull request should include tests. 98 | 2. If the pull request adds functionality, the docs should be updated. Put 99 | your new functionality into a function with a docstring, and add the 100 | feature to the list in README.rst. 101 | 3. The pull request should work for all recent Python versions. 102 | 103 | Tips 104 | ---- 105 | 106 | To run a subset of tests:: 107 | 108 | $ py.test tests.test_changelogs 109 | 110 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.15.1-dev (master) 6 | ------------------- 7 | * Current unstable version 8 | 9 | 0.15.0 (2020-12-27) 10 | ------------------- 11 | * Removed support for Python 2.7, 3.4 and 3.5 12 | * Added support for Python 3.9 13 | * Getting proper changelogs for beautifulsoup4 PyPi package again 14 | * Getting proper changelogs for synapse PyPi package again 15 | * Stopped using bumpversion 16 | * Updated PyPi map.txt to reflect different packages changelogs location changes 17 | * Fixed bug while processing domain-only URLs (#155) 18 | 19 | 0.14.0 (2018-01-9) 20 | ------------------- 21 | * Added a pypi/map.txt file to add custom URLS more easily 22 | * Added a bunch of custom URLS: 23 | - pytest-flake8 24 | - cornice.ext.swagger 25 | - python-social-core 26 | - python-social-auth 27 | - cx-oracle 28 | - plotnine 29 | - django-hijack 30 | - pyinvoke 31 | - gitpython 32 | - python-memcached 33 | - appenlight-client 34 | 35 | 0.13.0 (2018-01-9) 36 | ------------------- 37 | * Added a bunch of custom parser: 38 | - robozilla 39 | - websocket-client 40 | - pep8-naming 41 | - py-trello 42 | - synapse 43 | - django-haystack 44 | - libsass 45 | - lazy-object-proxy 46 | 47 | 0.12.0 (2017-05-18) 48 | ------------------- 49 | * Added a bunch of custom parser: 50 | - flake8 51 | - pyyaml 52 | - six 53 | - factory-boy 54 | - jinja2 55 | - docutils 56 | - sphinx-rtd-theme 57 | - whitenoise 58 | - numpy 59 | - beautifulsoup4 60 | - mccabe 61 | - django-braces 62 | - alabaster 63 | - cffi 64 | - django-coverage-plugin 65 | - newrelic 66 | - pandas 67 | - twine 68 | - pep8-naming 69 | - django-storages-redux 70 | - pbr 71 | 72 | 73 | 0.11.0 (2017-05-10) 74 | ------------------- 75 | 76 | * The changelog finder now checks repo URLs if they contain the given project name. This should 77 | make it easier to identify false changelogs. 78 | * Fixed a couple of internal errors on edge cases. 79 | * Added custom parsers for: 80 | - graphene 81 | - beautifulsoup4 82 | 83 | 0.10.0 (2017-04-26) 84 | ------------------- 85 | * Added support for GitHub release pages 86 | * Added experimental support for git commit log parsing 87 | 88 | 0.9.0 (2017-04-05) 89 | ------------------ 90 | 91 | * Fix issue with custom parsing of packages with different case. 92 | * Catch errors from launchpad. 93 | * Add support for changing project name when switching vendors. 94 | * Add support for finding URLs in the project description. 95 | * Add support for ex code.google.com projects, now moved to github. 96 | * Add support for parsing sourceforge repos. 97 | * Added custom parser: 98 | - alembic 99 | - genshi 100 | - imapclient 101 | - mako 102 | - pyinotify 103 | - python-ldap 104 | - redis 105 | - uwsgi 106 | - pyaudio 107 | 108 | 0.8.0 (2017-03-29) 109 | ------------------ 110 | 111 | * added custom parser: 112 | - mysqlclient, thanks @alexkiro 113 | * added custom launchpad backend, thanks to @alexkiro 114 | 115 | 0.7.0 (2017-03-06) 116 | ------------------ 117 | 118 | * added custom parsers 119 | - cheroot 120 | - pyparsing 121 | - gunicorn 122 | - sqlalchemy 123 | - djangorestframework 124 | * tweaked the get_head function 125 | 126 | 0.6.1 (2017-02-08) 127 | ------------------ 128 | 129 | * added flake8 special parser 130 | 131 | 0.6.0 (2017-02-03) 132 | ------------------ 133 | 134 | * tweaked the parser, included tests for openpyxl 135 | 136 | 0.5.0 (2017-01-23) 137 | ------------------ 138 | 139 | * include docs-src as docs candidate 140 | 141 | 0.4.0 (2017-01-23) 142 | ------------------ 143 | 144 | * add better support for NPM packages 145 | 146 | 0.3.3 (2017-01-05) 147 | ------------------ 148 | 149 | * fix packagin error (hopefully) 150 | 151 | 0.3.2 (2017-01-05) 152 | ------------------ 153 | 154 | * use modules for custom imports, for packaging 155 | 156 | 0.3.1 (2017-01-03) 157 | ------------------ 158 | 159 | * the find_changelogs and get_urls functions now also return the repo URLs 160 | 161 | 0.3.0 (2017-01-03) 162 | ------------------ 163 | 164 | * allow to swap in the find_changelogs function 165 | 166 | 0.2.0 (2016-12-27) 167 | ------------------ 168 | 169 | * added support for rubygems 170 | * added support for npm 171 | 172 | 0.1.0 (2016-12-19) 173 | ------------------ 174 | 175 | * First release on PyPI. 176 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2016, Jannis Gebauer 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include AUTHORS.rst 3 | 4 | include CONTRIBUTING.rst 5 | include HISTORY.rst 6 | include LICENSE 7 | include README.rst 8 | 9 | recursive-include changelogs/custom/pypi * 10 | recursive-include changelogs/custom/npm * 11 | recursive-include changelogs/custom/gem * 12 | 13 | recursive-include tests * 14 | recursive-exclude * __pycache__ 15 | recursive-exclude * *.py[co] 16 | 17 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | 14 | define PRINT_HELP_PYSCRIPT 15 | import re, sys 16 | 17 | for line in sys.stdin: 18 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 19 | if match: 20 | target, help = match.groups() 21 | print("%-20s %s" % (target, help)) 22 | endef 23 | export PRINT_HELP_PYSCRIPT 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | 32 | clean-build: ## remove build artifacts 33 | rm -fr build/ 34 | rm -fr dist/ 35 | rm -fr .eggs/ 36 | find . -name '*.egg-info' -exec rm -fr {} + 37 | find . -name '*.egg' -exec rm -f {} + 38 | 39 | clean-pyc: ## remove Python file artifacts 40 | find . -name '*.pyc' -exec rm -f {} + 41 | find . -name '*.pyo' -exec rm -f {} + 42 | find . -name '*~' -exec rm -f {} + 43 | find . -name '__pycache__' -exec rm -fr {} + 44 | 45 | clean-test: ## remove test and coverage artifacts 46 | rm -fr .tox/ 47 | rm -f .coverage 48 | rm -fr htmlcov/ 49 | 50 | lint: ## check style with flake8 51 | flake8 changelogs tests 52 | 53 | test: ## run tests quickly with the default Python 54 | py.test 55 | 56 | 57 | test-all: ## run tests on every Python version with tox 58 | tox 59 | 60 | coverage: ## check code coverage quickly with the default Python 61 | coverage run --source changelogs -m pytest 62 | 63 | coverage report -m 64 | coverage html 65 | $(BROWSER) htmlcov/index.html 66 | 67 | docs: ## generate Sphinx HTML documentation, including API docs 68 | rm -f docs/changelogs.rst 69 | rm -f docs/modules.rst 70 | sphinx-apidoc -o docs/ changelogs 71 | $(MAKE) -C docs clean 72 | $(MAKE) -C docs html 73 | $(BROWSER) docs/_build/html/index.html 74 | 75 | servedocs: docs ## compile the docs watching for changes 76 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 77 | 78 | release: clean ## package and upload a release 79 | python setup.py sdist upload 80 | python setup.py bdist_wheel upload 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/changelogs.svg 2 | :target: https://pypi.python.org/pypi/changelogs 3 | 4 | .. image:: https://img.shields.io/travis/pyupio/changelogs.svg 5 | :target: https://travis-ci.org/pyupio/changelogs 6 | 7 | .. image:: https://readthedocs.org/projects/changelogs/badge/?version=latest 8 | :target: https://changelogs.readthedocs.io/en/latest/?badge=latest 9 | :alt: Documentation Status 10 | 11 | .. image:: https://pyup.io/repos/github/pyupio/changelogs/shield.svg 12 | :target: https://pyup.io/repos/github/pyupio/changelogs/ 13 | :alt: Updates 14 | 15 | A changelog finder and parser with command line interface for packages available on pypi, npm, rubygems and launchpad.net. Originally developed for pyup.io_. 16 | 17 | .. _pyup.io: https://pyup.io/ 18 | 19 | 20 | ************ 21 | Installation 22 | ************ 23 | 24 | To install changelogs, run this command in your terminal: 25 | 26 | .. code-block:: console 27 | 28 | $ pip install changelogs 29 | 30 | ***** 31 | Usage 32 | ***** 33 | 34 | To use changelogs in a Python project:: 35 | 36 | import changelogs 37 | 38 | logs = changelogs.get("flask") 39 | logs = changelogs.get("babel", vendor="npm") 40 | logs = changelogs.get("bundler", vendor="npm") 41 | 42 | 43 | Or, from the command line:: 44 | 45 | changelogs flask 46 | changelogs babel npm 47 | changelogs bundler gem 48 | 49 | If you are on macOS, you can chain the `open` command:: 50 | 51 | changelogs babel npm >> babel.log && open babel.log 52 | 53 | 54 | ***** 55 | About 56 | ***** 57 | 58 | When trying to get a changelog for a given package, there are a bunch of problems: 59 | 60 | - There is no central place to store a changelog. If a project has a changelog, it's most likely somewhere in the git repo at all kinds of different places. This makes it hard to find. 61 | - The package index meta data often has no direct link to the git repo. This makes the repo hard to find. 62 | - There is no changelog standard. Everyone uses a different approach. This makes it hard to parse. 63 | 64 | This project is trying to solve this by: 65 | 66 | - first querying the package vendor for package meta data like the homepage or docs URL. 67 | - if the meta data doesn't contain a valid URL to a repo, visit all available URLs and scrape them to find one. 68 | - if there is a valid repo URL, visit the repo and look for possible changelogs like `Changes.txt`, `NEWS.md` or `history.rst`. 69 | - fetch the content and somewhat try to parse it. 70 | 71 | 72 | -------------------------------------------------------------------------------- /changelogs/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | from .changelogs import get, get_commit_log # noqa: F401 4 | 5 | """ 6 | if os.environ.get("DEBUG", "") in ("TRUE", "True", "true"): 7 | DEBUG = True 8 | import logging 9 | logging.basicConfig(level=logging.DEBUG) 10 | else: 11 | DEBUG = False 12 | """ 13 | 14 | __author__ = """pyup.io""" 15 | __email__ = 'support@pyup.io' 16 | __version__ = '0.15.1-dev' 17 | 18 | 19 | url_re = re.compile(r"(https?://[^\s<>\"'\x7f-\xff]+)", re.IGNORECASE) 20 | -------------------------------------------------------------------------------- /changelogs/changelogs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import functools 3 | import subprocess 4 | from tempfile import mkdtemp 5 | import imp 6 | import requests 7 | import os 8 | import re 9 | from requests import Session 10 | import logging 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | ALLOWED_CUSTOM_FUNCTIONS = ("parse", "get_head", "get_urls", 15 | "get_content") 16 | 17 | GITHUB_API_TOKEN = os.environ.get("CHANGELOGS_GITHUB_API_TOKEN", False) 18 | 19 | CHARS_LIMIT = os.environ.get("CHARS_LIMIT", 2000000) # default is 2 MB of 1 byte/char 20 | 21 | 22 | def _load_custom_functions(vendor, name): 23 | """ 24 | Loads custom functions from custom/{vendor}/{name}.py. This allows to quickly override any 25 | function that is used to retrieve and parse the changelog. 26 | :param name: str, package name 27 | :param vendor: str, vendor 28 | :return: dict, functions 29 | """ 30 | functions = {} 31 | # Some packages have dash in their name, replace them with underscore 32 | # E.g. python-ldap to python_ldap 33 | filename = "{}.py".format(name.replace("-", "_").lower()) 34 | path = os.path.join( 35 | os.path.dirname(os.path.realpath(__file__)), # current working dir 36 | "custom", # /dir/parser 37 | vendor, # /dir/parser/pypi 38 | filename # /dir/parser/pypi/django.py 39 | ) 40 | if os.path.isfile(path): 41 | module_name = "parser.{vendor}.{name}".format(vendor=vendor, name=name) 42 | module = imp.load_source(module_name, path) 43 | functions = dict( 44 | (function, getattr(module, function, None)) for function in ALLOWED_CUSTOM_FUNCTIONS 45 | if hasattr(module, function) 46 | ) 47 | return functions 48 | 49 | 50 | def _bootstrap_functions(name, vendor, functions): 51 | """ 52 | Loads all functions, including custom functions, for the given package/vendor and updates it 53 | with the functions passed to this function. [:)] 54 | It loads the default functions first, then the custom functions and lastly the functions passed 55 | to this function (if any). [:)] 56 | :param name: str, package name 57 | :param vendor: str, vendor 58 | :param functions: dict, custom functions 59 | :return: dict, functions 60 | """ 61 | # load default functions 62 | from . import parser 63 | from . import finder 64 | fns = { 65 | "get_content": get_content, 66 | "parse": parser.parse, 67 | "get_head": parser.get_head, 68 | "find_changelogs": finder.find_changelogs 69 | } 70 | 71 | # load vendor functions 72 | if vendor == "pypi": 73 | from . import pypi 74 | fns.update({ 75 | "get_metadata": pypi.get_metadata, 76 | "get_releases": pypi.get_releases, 77 | "get_urls": pypi.get_urls, 78 | }) 79 | elif vendor == "npm": 80 | from . import npm 81 | fns.update({ 82 | "get_metadata": npm.get_metadata, 83 | "get_releases": npm.get_releases, 84 | "get_urls": npm.get_urls, 85 | }) 86 | elif vendor == "gem": 87 | from . import rubygems 88 | fns.update({ 89 | "get_metadata": rubygems.get_metadata, 90 | "get_releases": rubygems.get_releases, 91 | "get_urls": rubygems.get_urls, 92 | }) 93 | elif vendor == "launchpad": 94 | from . import launchpad 95 | fns.update({ 96 | "get_metadata": launchpad.get_metadata, 97 | "get_releases": launchpad.get_releases, 98 | "get_urls": launchpad.get_urls, 99 | "find_changelogs": launchpad.find_changelogs, 100 | "get_content": launchpad.get_content, 101 | "parse": launchpad.parse 102 | }) 103 | 104 | # load custom functions for special packages lying in custom/{vendor}/{package}.py 105 | custom_fns = _load_custom_functions(vendor=vendor, name=name) 106 | fns.update(custom_fns) 107 | 108 | # update custom functions with those passed in here. This allows 109 | fns.update(functions) 110 | return fns 111 | 112 | 113 | def check_for_launchpad(old_vendor, name, urls): 114 | """Check if the project is hosted on launchpad. 115 | 116 | :param name: str, name of the project 117 | :param urls: set, urls to check. 118 | :return: the name of the project on launchpad, or an empty string. 119 | """ 120 | if old_vendor != "pypi": 121 | # XXX This might work for other starting vendors 122 | # XXX but I didn't check. For now only allow 123 | # XXX pypi -> launchpad. 124 | return '' 125 | 126 | for url in urls: 127 | try: 128 | return re.match(r"https?://launchpad.net/([\w.\-]+)", 129 | url).groups()[0] 130 | except AttributeError: 131 | continue 132 | return '' 133 | 134 | 135 | def check_switch_vendor(old_vendor, name, urls, _depth=0): 136 | """Check if the project should switch vendors. E.g 137 | project pushed on pypi, but changelog on launchpad. 138 | 139 | :param name: str, name of the project 140 | :param urls: set, urls to check. 141 | :return: tuple, (str(new vendor name), str(new project name)) 142 | """ 143 | if _depth > 3: 144 | # Protect against recursive things vendors here. 145 | return "" 146 | new_name = check_for_launchpad(old_vendor, name, urls) 147 | if new_name: 148 | return "launchpad", new_name 149 | return "", "" 150 | 151 | 152 | def get(name, vendor="pypi", functions={}, chars_limit=CHARS_LIMIT, _depth=0): 153 | """ 154 | Tries to find a changelog for the given package. 155 | :param name: str, package name 156 | :param vendor: str, vendor 157 | :param functions: dict, custom functions 158 | :param chars_limit: int, changelog content entry chars limit 159 | :return: dict, changelog 160 | """ 161 | fns = _bootstrap_functions(name=name, vendor=vendor, functions=functions) 162 | session = Session() 163 | session.request = functools.partial(session.request, timeout=10) 164 | session.max_redirects = 3 165 | 166 | # get meta data for the given package and use this metadata to 167 | # find urls pointing to a possible changelog 168 | data = fns["get_metadata"](session=session, name=name) 169 | releases = fns["get_releases"](name=name, data=data) 170 | urls, repos = fns["get_urls"]( 171 | session=session, 172 | name=name, 173 | data=data, 174 | releases=releases, 175 | find_changelogs_fn=fns["find_changelogs"] 176 | ) 177 | 178 | # load the content from the given urls and parse the changelog 179 | content = fns["get_content"](session=session, urls=urls, chars_limit=chars_limit) 180 | changelog = fns["parse"]( 181 | name=name, 182 | content=content, 183 | releases=releases, 184 | get_head_fn=fns["get_head"] 185 | ) 186 | del fns 187 | if changelog: 188 | return changelog 189 | 190 | # We could not find any changelogs. 191 | # Check to see if we can switch vendors. 192 | new_vendor, new_name = check_switch_vendor(vendor, name, repos, 193 | _depth=_depth) 194 | if new_vendor and new_vendor != vendor: 195 | return get(new_name, vendor=new_vendor, functions=functions, 196 | _depth=_depth+1) 197 | return {} 198 | 199 | 200 | def get_commit_log(name, vendor='pypi', functions={}, _depth=0): 201 | """ 202 | Tries to parse a changelog from the raw commit log. 203 | :param name: str, package name 204 | :param vendor: str, vendor 205 | :param functions: dict, custom functions 206 | :return: tuple, (dict -> commit log, str -> raw git log) 207 | """ 208 | if "find_changelogs" not in functions: 209 | from .finder import find_git_repo 210 | functions["find_changelogs"] = find_git_repo 211 | if "get_content" not in functions: 212 | functions["get_content"] = clone_repo 213 | if "parse" not in functions: 214 | from .parser import parse_commit_log 215 | functions["parse"] = parse_commit_log 216 | return get( 217 | name=name, 218 | vendor=vendor, 219 | functions=functions 220 | ) 221 | 222 | 223 | def get_limited_content_entry(session, url, chars_limit): 224 | """ 225 | Loads the content for an URL entry till chars_limit. 226 | :param session: requests Session instance 227 | :param url: str URL 228 | :param chars_limit: int, changelog content entry chars limit 229 | :return: str, limited content 230 | """ 231 | limited_content = "" 232 | with session.get(url, stream=True) as resp: 233 | if resp.status_code == 200: 234 | try: 235 | # Avoid https://github.com/psf/requests/issues/3359 236 | if not resp.encoding: 237 | resp.encoding = 'utf-8' 238 | limited_content = resp.iter_content(chunk_size=chars_limit, 239 | decode_unicode=True).__next__() 240 | except StopIteration: 241 | pass 242 | return limited_content 243 | 244 | 245 | def get_content(session, urls, chars_limit): 246 | """ 247 | Loads the content from URLs, ignoring connection errors. 248 | :param session: requests Session instance 249 | :param urls: list, str URLs 250 | :param chars_limit: int, changelog content entry chars limit 251 | :return: str, content 252 | """ 253 | content = "" 254 | for url in urls: 255 | try: 256 | logger.debug("GET changelog from {url}".format(url=url)) 257 | if "https://api.github.com" in url and url.endswith("releases"): 258 | # this is a github API release page, fetch it if token is set 259 | if not GITHUB_API_TOKEN: 260 | logger.warning("Fetching release pages requires CHANGELOGS_GITHUB_API_TOKEN " 261 | "to be set") 262 | continue 263 | page = 0 264 | exist_pages = True 265 | headers = { 266 | "Authorization": "token {}".format(GITHUB_API_TOKEN) 267 | } 268 | 269 | while exist_pages: 270 | resp = session.get(url, headers=headers, params={'page': page}) 271 | if resp.status_code == 200 and len(resp.json()) > 0: 272 | # These entries are limited by GH to max 125 kB 273 | for item in resp.json(): 274 | if 'tag_name' in item and 'body' in item: 275 | content += "\n\n{}\n{}".format(item['tag_name'], item["body"]) 276 | else: 277 | exist_pages = False 278 | 279 | page += 1 280 | 281 | else: 282 | content += "\n\n" + get_limited_content_entry(session, url, chars_limit) 283 | 284 | # To avoid exceeding the content limit by accumulation 285 | if len(content) > chars_limit: 286 | break 287 | 288 | except requests.ConnectionError: 289 | pass 290 | return content 291 | 292 | 293 | def clone_repo(session, urls): 294 | """ 295 | Clones the given repos in temp directories 296 | :param session: requests Session instance 297 | :param urls: list, str URLs 298 | :return: tuple, (str -> directory, str -> URL) 299 | """ 300 | repos = [] 301 | for url in urls: 302 | dir = mkdtemp() 303 | call = ["git", "clone", url, dir] 304 | subprocess.call(call) 305 | repos.append((dir, url)) 306 | return repos 307 | -------------------------------------------------------------------------------- /changelogs/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import changelogs 3 | from packaging.version import parse 4 | import argparse 5 | import logging 6 | 7 | 8 | def main(): 9 | parser = argparse.ArgumentParser( 10 | description='A changelog finder and parser for packages available on pypi, npm and rubygems.' 11 | ) 12 | parser.add_argument("package", help="package name") 13 | parser.add_argument("vendor", help="vendor (pypi, npm, gem)", default="pypi", nargs='?') 14 | parser.add_argument("-v", "--verbose", help="increase output verbosity", 15 | action="store_true") 16 | parser.add_argument("-c", "--commits", help="", 17 | action="store_true") 18 | 19 | args = parser.parse_args() 20 | if args.verbose: 21 | logging.basicConfig(level=logging.DEBUG) 22 | 23 | if args.commits: 24 | data, raw_log = changelogs.get_commit_log(args.package, vendor=args.vendor) 25 | else: 26 | data = changelogs.get(args.package, vendor=args.vendor) 27 | 28 | for release in sorted(data.keys(), key=lambda v: parse(v), reverse=True): 29 | print(release) 30 | print(data[release]) 31 | 32 | if not data and args.commits: 33 | print(raw_log) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /changelogs/custom/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/changelogs/c2bf12f1eb752593b8b2fd0b3646fdd34f642a22/changelogs/custom/pypi/__init__.py -------------------------------------------------------------------------------- /changelogs/custom/pypi/alabaster.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | 4 | def get_urls(releases, **kwargs): 5 | return { 6 | 'https://alabaster.readthedocs.io/en/latest/_sources/changelog.rst.txt' 7 | }, set() 8 | 9 | 10 | def get_head(line, releases, **kwargs): 11 | for release in releases: 12 | if ":release:`{}".format(release) in line: 13 | return release 14 | return False 15 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/alembic.py: -------------------------------------------------------------------------------- 1 | """Custom parser for alembic""" 2 | 3 | # Alembic already has a changelog in 4 | # https://bitbucket.org/zzzeek/alembic/raw/master/CHANGES 5 | # But the content just says that it moved to this location. 6 | URL = "https://bitbucket.org/zzzeek/alembic/raw/master/docs/build/changelog.rst" 7 | 8 | 9 | def get_head(line, releases, **kwargs): 10 | # This is the same as SQLAlchemy. 11 | for release in releases: 12 | if " :version: {}".format(release) == line: 13 | return release 14 | return False 15 | 16 | 17 | def get_urls(releases, **kwargs): 18 | return {URL}, set() 19 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/beautifulsoup4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | import requests 4 | from lxml import etree 5 | 6 | 7 | def get_urls(releases, **kwargs): 8 | # changelog is on launchpad. 9 | r = requests.get('https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/CHANGELOG') 10 | root = etree.HTML(r.text) 11 | link = root.xpath("//a[contains(text(),'download file')]/@href")[0] 12 | return ["https://bazaar.launchpad.net" + link], set() 13 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/boto.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | urls = [] 3 | for release in releases: 4 | if release == "2.0": 5 | release = "2.0.0" 6 | urls.append( 7 | "https://raw.githubusercontent.com/boto/boto/develop/docs/source/releasenotes/v{v}.rst" 8 | .format(v=release)) 9 | return urls, [] 10 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/cffi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from lxml import etree 3 | 4 | 5 | def get_urls(releases, **kwargs): 6 | urls = set() 7 | # unable to link to latest on bitbucket. have to scrape the URL 8 | r = requests.get( 9 | 'https://bitbucket.org/cffi/release-doc/src/default/doc/source/whatsnew.rst' + 10 | '?fileviewer=file-view-default') 11 | if r.status_code == 200: 12 | tree = etree.HTML(r.content) 13 | # find the link that ends with whatsnew.txt 14 | for link in frozenset([str(href).split("?")[0] for href in tree.xpath("//a/@href")]): 15 | if link.endswith("whatsnew.rst") and "/raw/" in link: 16 | urls.add("https://bitbucket.org" + link) 17 | return urls, set() 18 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/cheroot.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return [ 3 | 'https://raw.githubusercontent.com/cherrypy/cheroot/master/CHANGES.rst' 4 | ], set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/django.py: -------------------------------------------------------------------------------- 1 | def get_head(line, releases, **kwargs): 2 | for release in releases: 3 | if "Django {} release notes".format(release) in line: 4 | return release 5 | return False 6 | 7 | 8 | def get_urls(releases, **kwargs): 9 | urls = [] 10 | for release in releases: 11 | urls.append("https://raw.githubusercontent.com/django/django/master/docs/releases/{v}.txt" 12 | .format(v=release)) 13 | return urls, [] 14 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/django_braces.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/brack3t/django-braces/master/docs/changelog.rst' 4 | }, set() 5 | 6 | 7 | def get_head(line, releases, **kwargs): 8 | for release in releases: 9 | if "* :release:`{}".format(release) in line: 10 | return release 11 | return False 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/django_coverage_plugin.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/nedbat/django_coverage_plugin/master/README.rst' 4 | }, set() 5 | 6 | 7 | def get_head(line, releases, **kwargs): 8 | for release in releases: 9 | if "v{} --- ".format(release) in line: 10 | return release 11 | return False 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/django_haystack.py: -------------------------------------------------------------------------------- 1 | URL = 'https://raw.githubusercontent.com/django-haystack/django-haystack/master/docs/changelog.rst' 2 | 3 | 4 | def get_urls(releases, **kwargs): 5 | return {URL}, set() 6 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/django_storages_redux.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/jschneier/django-storages/master/CHANGELOG.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/djangorestframework.py: -------------------------------------------------------------------------------- 1 | URL_PREFIX = 'https://raw.githubusercontent.com/tomchristie/django-rest-framework/' 2 | 3 | 4 | def get_urls(releases, **kwargs): 5 | return [ 6 | URL_PREFIX + 'master/docs/topics/release-notes.md', 7 | URL_PREFIX + 'version-2.4.x/docs/topics/release-notes.md' 8 | ], set() 9 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/docutils.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'http://docutils.sourceforge.net/RELEASE-NOTES.txt' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/factory_boy.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/FactoryBoy/factory_boy/master/docs/changelog.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/flake8.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | urls = [] 3 | for release in releases: 4 | # releases like 0.6 are actually saved as 0.6.0. 5 | if len(release.split('.')) == 2: 6 | release += '.0' 7 | urls.append( 8 | "https://gitlab.com/pycqa/flake8/raw/master/docs/source/release-notes/{v}.rst" 9 | .format(v=release) 10 | ) 11 | return urls, [] 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/genshi.py: -------------------------------------------------------------------------------- 1 | """Custom parser for Genshi""" 2 | 3 | URL = "https://genshi.edgewall.org/export/head/trunk/ChangeLog" 4 | 5 | 6 | def get_urls(releases, **kwargs): 7 | return {URL}, set() 8 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/graphene.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | 4 | 5 | def get_urls(releases, **kwargs): 6 | # graphene has a releases changelog 7 | return [ 8 | "https://api.github.com/repos/graphql-python/graphene/releases", 9 | ], set() 10 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/gunicorn.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | def get_urls(releases, **kwargs): 5 | # gunicorn uses {year}-news.rst 6 | urls = [ 7 | "https://raw.githubusercontent.com/benoitc/gunicorn/master/docs/source/{}-news.rst" 8 | .format(year) for year in range(2010, datetime.now().year + 1) 9 | ] 10 | return urls, set() 11 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/imapclient.py: -------------------------------------------------------------------------------- 1 | """Custom parser for IMAPClient""" 2 | 3 | URL = ("https://raw.githubusercontent.com/mjs/imapclient/master/doc/src" 4 | "/releases.rst") 5 | 6 | 7 | def get_head(name, line, releases): 8 | if not line.strip().startswith("Version"): 9 | return False 10 | 11 | return line.strip().rsplit(None, 1)[1] 12 | 13 | 14 | def get_urls(releases, **kwargs): 15 | return {URL}, set() 16 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/jinja2.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/pallets/jinja/main/CHANGES.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/lazy-object-proxy.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/ionelmc/python-lazy-object-proxy/master/CHANGELOG.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/libsass.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return {'https://raw.githubusercontent.com/dahlia/libsass-python/master/docs/changes.rst'}, \ 3 | set() 4 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/mako.py: -------------------------------------------------------------------------------- 1 | """Custom parser for alembic""" 2 | 3 | # Alembic already has a changelog in 4 | # https://bitbucket.org/zzzeek/mako/raw/master/CHANGES 5 | # But the content just says that it moved to this location. 6 | URL = "https://bitbucket.org/zzzeek/mako/raw/master/doc/build/changelog.rst" 7 | 8 | 9 | def get_head(line, releases, **kwargs): 10 | # This is the same as SQLAlchemy. 11 | for release in releases: 12 | if " :version: {}".format(release) == line: 13 | return release 14 | return False 15 | 16 | 17 | def get_urls(releases, **kwargs): 18 | return {URL}, set() 19 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/map.txt: -------------------------------------------------------------------------------- 1 | ampache: https://api.github.com/repos/ampache/python3-ampache/releases 2 | apache-superset: https://raw.githubusercontent.com/apache/incubator-superset/master/CHANGELOG.md 3 | apollo: https://api.github.com/repos/galaxy-genome-annotation/python-apollo/releases 4 | archi: https://raw.githubusercontent.com/whtsky/archi/master/README.md 5 | cornice-swagger: https://raw.githubusercontent.com/Cornices/cornice.ext.swagger/master/CHANGES.rst 6 | django-easy-select2: https://raw.githubusercontent.com/asyncee/django-easy-select2/master/docs/source/changelog.rst 7 | django-redis-cache: https://raw.githubusercontent.com/sebleier/django-redis-cache/master/README.rst 8 | django-registration-redux: https://raw.githubusercontent.com/macropin/django-registration/master/CHANGELOG 9 | django-rest-framework: https://raw.githubusercontent.com/encode/django-rest-framework/master/docs/community/release-notes.md 10 | fritzconnection: https://raw.githubusercontent.com/kbr/fritzconnection/master/docs/sources/changes.rst 11 | future: https://raw.githubusercontent.com/PythonCharmers/python-future/master/docs/whatsnew.rst 12 | gunicorn: https://raw.githubusercontent.com/benoitc/gunicorn/master/docs/source/news.rst 13 | homeassistant: https://api.github.com/repos/home-assistant/core/releases 14 | honeycomb-beeline: https://raw.githubusercontent.com/honeycombio/beeline-python/main/CHANGELOG.md 15 | httpretty: https://raw.githubusercontent.com/gabrielfalcao/HTTPretty/master/docs/source/changelog.rst 16 | hypothesis: https://raw.githubusercontent.com/HypothesisWorks/hypothesis/master/hypothesis-python/docs/changes.rst 17 | importlib-metadata: https://gitlab.com/python-devs/importlib_metadata/-/raw/master/docs/changelog.rst 18 | jinja: https://raw.githubusercontent.com/pallets/jinja/master/CHANGES.rst 19 | json-e: https://raw.githubusercontent.com/taskcluster/json-e/master/CHANGELOG.rst 20 | kiwisolver: https://raw.githubusercontent.com/nucleic/kiwi/master/releasenotes.rst 21 | kombu: https://raw.githubusercontent.com/celery/kombu/master/Changelog.rst 22 | monero: https://monero-python.readthedocs.io/en/latest/_sources/release_notes.rst.txt 23 | more-itertools: https://raw.githubusercontent.com/more-itertools/more-itertools/master/docs/versions.rst 24 | msgpack: https://raw.githubusercontent.com/msgpack/msgpack-python/master/ChangeLog.rst 25 | nbdime: https://raw.githubusercontent.com/jupyter/nbdime/master/docs/source/changelog.md 26 | numpy: https://api.github.com/repos/numpy/numpy/releases 27 | openpyxl: https://openpyxl.readthedocs.io/en/stable/_sources/changes.rst.txt 28 | osc: https://raw.githubusercontent.com/openSUSE/osc/master/NEWS 29 | pdfminer.six: https://raw.githubusercontent.com/pdfminer/pdfminer.six/master/CHANGELOG.md 30 | phonenumbers: https://raw.githubusercontent.com/google/libphonenumber/master/release_notes.txt 31 | pinax-stripe: https://raw.githubusercontent.com/pinax/pinax-stripe/master/docs/about/release-notes.md 32 | poetry: https://raw.githubusercontent.com/python-poetry/poetry/refs/heads/main/CHANGELOG.md 33 | pytest: https://raw.githubusercontent.com/pytest-dev/pytest/master/doc/en/changelog.rst 34 | python-memcached: https://raw.githubusercontent.com/linsomniac/python-memcached/master/ChangeLog 35 | python-social-auth: https://raw.githubusercontent.com/python-social-auth/social-core/master/CHANGELOG.md 36 | pytz: https://raw.githubusercontent.com/stub42/pytz/master/tz/NEWS 37 | pyyaml: https://raw.githubusercontent.com/yaml/pyyaml/master/CHANGES 38 | pyzmq: https://raw.githubusercontent.com/zeromq/pyzmq/master/docs/source/changelog.rst 39 | raiden: https://raw.githubusercontent.com/raiden-network/raiden/develop/docs/changelog.rst 40 | safety: https://raw.githubusercontent.com/pyupio/safety/master/CHANGELOG.md 41 | scalelite: https://api.github.com/repos/alexsanderp/scalelite-python-wrapper/releases 42 | sentry-sdk: https://raw.githubusercontent.com/getsentry/sentry-python/master/CHANGES.md 43 | sphinx-rtd-theme: https://raw.githubusercontent.com/rtfd/sphinx_rtd_theme/master/docs/changelog.rst 44 | sphinxcontrib-napoleon: https://raw.githubusercontent.com/sphinx-contrib/napoleon/master/CHANGES 45 | sqlalchemy-json: https://raw.githubusercontent.com/edelooff/sqlalchemy-json/master/README.rst 46 | tox: https://raw.githubusercontent.com/tox-dev/tox/master/docs/changelog.rst 47 | tuf: https://raw.githubusercontent.com/theupdateframework/tuf/develop/docs/CHANGELOG.md 48 | zulip: https://raw.githubusercontent.com/zulip/python-zulip-api/master/zulip/CHANGELOG.md 49 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/mccabe.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return {'https://raw.githubusercontent.com/PyCQA/mccabe/master/README.rst'}, set() 3 | 4 | 5 | def get_head(line, releases, **kwargs): 6 | for release in releases: 7 | if "{} - ".format(release) in line: 8 | return release 9 | return False 10 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/mysqlclient.py: -------------------------------------------------------------------------------- 1 | """Custom parsing for mysqlclient.""" 2 | 3 | 4 | def get_head(name, line, releases): 5 | """Parses content from: 6 | 7 | https://github.com/PyMySQL/mysqlclient-python/blob/master/HISTORY.md 8 | 9 | Each HEAD line looks like: 10 | 11 | What's new in 1.3.10 12 | 13 | """ 14 | if not line.strip().startswith("What's new in"): 15 | return False 16 | 17 | return line.strip().rsplit(None, 1)[1] 18 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/newrelic.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | from changelogs.changelogs import get_limited_content_entry 3 | import sys 4 | 5 | 6 | def get_urls(releases, **kwargs): 7 | urls = set() 8 | for release in releases: 9 | urls.add( 10 | 'https://docs.newrelic.com/docs/release-notes/agent-release-notes/' 11 | 'python-release-notes/python-agent-{}'.format(release.replace('.', '')) 12 | ) 13 | return urls, set() 14 | 15 | 16 | def get_content(session, urls, chars_limit): 17 | log = "" 18 | for url in urls: 19 | limited_content_entry = get_limited_content_entry(session, url, chars_limit) 20 | if limited_content_entry: 21 | root = etree.HTML(limited_content_entry) 22 | else: 23 | continue 24 | try: 25 | article = root.xpath("//article/div[@class='content']")[0] 26 | content = etree.tostring(article, method="text", encoding='utf-8') 27 | if sys.version_info > (3, 0): 28 | content = content.decode("utf-8") 29 | # remove first two lines 30 | content = '\n'.join(content.split('\n')[2:-1]) 31 | log += "# {version}\n{content}\n\n".format( 32 | version=url.split("-")[-1], 33 | content=content, 34 | ) 35 | except IndexError: 36 | pass 37 | return log 38 | 39 | 40 | def get_head(line, releases, **kwargs): 41 | for release in releases: 42 | if "# {}".format(release.replace(".", "")) == line: 43 | return release 44 | return False 45 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pandas.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | urls = set() 3 | for release in releases: 4 | urls.add( 5 | 'https://raw.githubusercontent.com' 6 | '/pandas-dev/pandas/master/doc/source/whatsnew/v{release}.txt'.format(release=release) 7 | ) 8 | return urls, set() 9 | 10 | 11 | def get_head(line, releases, **kwargs): 12 | for release in releases: 13 | if "v{} (".format(release) in line or "v.{} (".format(release) in line: 14 | return release 15 | return False 16 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pbr.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from lxml import etree 3 | from changelogs.changelogs import get_limited_content_entry 4 | 5 | 6 | def get_urls(releases, **kwargs): 7 | return { 8 | 'https://docs.openstack.org/developer/pbr/history.html' 9 | }, set() 10 | 11 | 12 | def get_content(session, urls, chars_limit): 13 | log = "" 14 | for url in urls: 15 | limited_content_entry = get_limited_content_entry(session, url, chars_limit) 16 | if limited_content_entry: 17 | root = etree.HTML(limited_content_entry) 18 | else: 19 | continue 20 | for item in root.xpath("//div[@class='section']"): 21 | try: 22 | log += "{version}\n{content}\n\n".format( 23 | version=item.xpath("h3/text()")[0], 24 | content="\n".join(["- {}".format(li) for li in item.xpath("ul/li/text()")]) 25 | ) 26 | except IndexError: 27 | pass 28 | return log 29 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pep8_naming.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/PyCQA/pep8-naming/master/CHANGELOG.rst' 4 | }, set() 5 | 6 | 7 | def get_head(line, releases, **kwargs): 8 | for release in releases: 9 | if "{} - ".format(release) in line: 10 | return release 11 | return False 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/py.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return ["https://raw.githubusercontent.com/pytest-dev/py/master/CHANGELOG"], [] 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/py_trello.py: -------------------------------------------------------------------------------- 1 | URL = 'https://raw.githubusercontent.com/sarumont/py-trello/master/CHANGELOG' 2 | 3 | 4 | def get_head(line, releases, **kwargs): 5 | for release in releases: 6 | print("checking", release, "vs", line) 7 | if "v{}".format(release) == line: 8 | return release 9 | return False 10 | 11 | 12 | def get_urls(releases, **kwargs): 13 | return {URL}, set() 14 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pyaudio.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}") 4 | URL = ("https://people.csail.mit.edu/hubert/git/?" 5 | "p=pyaudio.git;a=blob_plain;f=CHANGELOG;hb=HEAD") 6 | 7 | 8 | def get_urls(releases, **kwargs): 9 | return {URL}, set() 10 | 11 | 12 | def parse(name, content, releases, get_head_fn): 13 | """ 14 | Parses the given content for a valid changelog 15 | :param name: str, package name 16 | :param content: str, content 17 | :param releases: list, releases 18 | :param get_head_fn: function 19 | :return: dict, changelog 20 | """ 21 | changelog = {} 22 | releases = frozenset(releases) 23 | head = False 24 | date_line = None 25 | for line in content.splitlines(): 26 | if DATE_RE.match(line): 27 | date_line = line 28 | continue 29 | if line.strip().startswith("PyAudio"): 30 | try: 31 | head = line.strip().split()[1] 32 | except Exception: 33 | continue 34 | changelog[head] = date_line + "\n" 35 | continue 36 | if not head: 37 | continue 38 | line = line.replace("@", "") 39 | line = line.replace("#", "") 40 | changelog[head] += line + "\n" 41 | return changelog 42 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pyinotify.py: -------------------------------------------------------------------------------- 1 | """Custom parser for PyInotify""" 2 | 3 | URL = ("https://raw.githubusercontent.com/wiki/seb-m/pyinotify/" 4 | "Recent-Developments.md") 5 | 6 | 7 | def get_urls(releases, **kwargs): 8 | return {URL}, set() 9 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/python_ldap.py: -------------------------------------------------------------------------------- 1 | """Custom parser for python-ldap""" 2 | 3 | URL = ("http://python-ldap.cvs.sourceforge.net/viewvc/python-ldap/python-ldap" 4 | "/CHANGES") 5 | 6 | 7 | def get_urls(releases, **kwargs): 8 | return {URL}, set() 9 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pytz.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return {'https://git.launchpad.net/pytz/plain/src/CHANGES.txt'}, set() 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/pyyaml.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return {"http://svn.pyyaml.org/pyyaml/trunk/CHANGES"}, set() 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/redis.py: -------------------------------------------------------------------------------- 1 | """Custom parser for redis""" 2 | 3 | 4 | def get_head(name, line, releases): 5 | if not line.startswith("* "): 6 | return False 7 | try: 8 | return line.split()[1] 9 | except IndexError: 10 | return False 11 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/robozilla.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/SatelliteQE/robozilla/master/HISTORY' 4 | }, set() 5 | 6 | 7 | def get_head(line, releases, **kwargs): 8 | for release in releases: 9 | if line.startswith(release) and line.endswith(")"): 10 | return release 11 | return False 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/selenium.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return ["https://raw.githubusercontent.com/SeleniumHQ/selenium/master/py/CHANGES"], [] 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/six.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return {'https://raw.githubusercontent.com/benjaminp/six/master/CHANGES'}, set() 3 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/sphinx_rtd_theme.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/rtfd/sphinx_rtd_theme/master/README.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | def get_head(line, releases, **kwargs): 2 | for release in releases: 3 | if " :version: {}".format(release) == line: 4 | return release 5 | return False 6 | 7 | 8 | def get_urls(releases, **kwargs): 9 | # sqlalchemy changelogs are stored as changelog_{major}{minor}.rst 10 | log_names = set(["".join(r.split('.')[:2]) for r in releases if "beta" not in r]) 11 | # sort urls to make them compatible for testing 12 | urls = sorted([ 13 | ("https://raw.githubusercontent.com/zzzeek/sqlalchemy/master/doc/build/changelog/" + 14 | "changelog_{}.rst").format(v) for v in log_names 15 | ]) 16 | return urls, set() 17 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/synapse.py: -------------------------------------------------------------------------------- 1 | 2 | def get_urls(releases, **kwargs): 3 | # Pypi has a old bugtracker_url which points to a separate repo which causes invalid 4 | # changelogs to be generated by this tool. 5 | ret = { 6 | 'https://raw.githubusercontent.com/vertexproject/synapse/master/CHANGELOG.rst', 7 | 'https://raw.githubusercontent.com/vertexproject/synapse/01x/CHANGELOG.rst', 8 | 'https://raw.githubusercontent.com/vertexproject/synapse/00x/CHANGELOG.md', 9 | } 10 | return ret, set() 11 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/twine.py: -------------------------------------------------------------------------------- 1 | def get_head(line, releases, **kwargs): 2 | for release in releases: 3 | if ":release:`{} ".format(release) in line: 4 | return release 5 | return False 6 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/uwsgi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from changelogs.changelogs import get_limited_content_entry 3 | 4 | 5 | def get_urls(releases, **kwargs): 6 | # sort urls to make them compatible for testing 7 | urls = sorted([ 8 | "https://raw.githubusercontent.com/unbit/uwsgi-docs/master/Changelog" 9 | "-{}.rst".format(v) for v in releases 10 | ]) 11 | return urls, set() 12 | 13 | 14 | def get_content(session, urls, chars_limit): 15 | content = {} 16 | for url in urls: 17 | v = url.rsplit("-", 1)[1].rsplit(".", 1)[0] 18 | try: 19 | resp_text = get_limited_content_entry(session, url, chars_limit) 20 | content[v] = resp_text.split("\n", 2)[-1] 21 | except requests.ConnectionError: 22 | pass 23 | return content 24 | 25 | 26 | def parse(name, content, releases, get_head_fn): 27 | return content 28 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/websocket_client.py: -------------------------------------------------------------------------------- 1 | def get_urls(releases, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/websocket-client/websocket-client/master/ChangeLog' 4 | }, set() 5 | 6 | 7 | def get_head(line, releases, **kwargs): 8 | for release in releases: 9 | if "- {}".format(release) == line or "- v{}".format(release) == line: 10 | return release 11 | return False 12 | -------------------------------------------------------------------------------- /changelogs/custom/pypi/whitenoise.py: -------------------------------------------------------------------------------- 1 | def get_urls(*args, **kwargs): 2 | return { 3 | 'https://raw.githubusercontent.com/evansd/whitenoise/master/docs/changelog.rst' 4 | }, set() 5 | -------------------------------------------------------------------------------- /changelogs/finder.py: -------------------------------------------------------------------------------- 1 | import validators 2 | from lxml import etree 3 | from requests import ConnectionError, RequestException 4 | import re 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def validate_url(url): 11 | """ 12 | Validates the URL 13 | :param url: 14 | :return: 15 | """ 16 | if validators.url(url): 17 | return url 18 | elif validators.domain(url): 19 | return "http://{}".format(url) 20 | return "" 21 | 22 | 23 | def validate_repo_url(url): 24 | """ 25 | Validates and formats `url` to be valid URL pointing to a repo on bitbucket.org or github.com 26 | :param url: str, URL 27 | :return: str, valid URL if valid repo, emptry string otherwise 28 | """ 29 | try: 30 | if "github.com" in url: 31 | return re.findall(r"https?://w?w?w?.?github.com/[\w\-]+/[\w.-]+", url)[0] 32 | elif "bitbucket.org" in url: 33 | return re.findall(r"https?://bitbucket.org/[\w.-]+/[\w.-]+", url)[0] + "/src/" 34 | elif "launchpad.net" in url: 35 | return re.findall(r"https?://launchpad.net/[\w.-]+", url)[0] 36 | elif "sourceforge.net" in url: 37 | mo = re.match(r"https?://sourceforge.net/projects/" 38 | r"([\w.-]+)/", url, re.I) 39 | template = "https://sourceforge.net/p/{}/code/HEAD/tree/trunk/src/" 40 | return template.format(mo.groups()[0]) 41 | except (IndexError, AttributeError): 42 | pass 43 | return "" 44 | 45 | 46 | def contains_project_name(name, link): 47 | """ 48 | Checks if the given link `somewhat` contains the project name. 49 | :param name: str, project name 50 | :param link: str, link 51 | :return: bool, True if the link contains the project name 52 | """ 53 | def unclutter(string): 54 | # strip out all python references and remove all excessive characters 55 | string = string.lower().replace("_", "-").replace(".", "-") 56 | for replace in ["python-", "py-", "-py", "-python"]: 57 | string = string.replace(replace, "") 58 | return re.sub("[^0123456789 a-zA-Z]", "", string).strip() 59 | return unclutter(name) in unclutter(link) 60 | 61 | 62 | def find_repo_urls(session, name, candidates): 63 | """ 64 | Visits the given URL candidates and searches the page for valid links to a repository. 65 | :param session: requests Session instance 66 | :param name: str, project name 67 | :param candidates: list, list of URL candidates 68 | :return: str, URL to a repo 69 | """ 70 | for _url in candidates: 71 | _url = validate_url(_url) 72 | if _url: 73 | try: 74 | resp = session.get(_url) 75 | if resp.status_code == 200: 76 | tree = etree.HTML(resp.content) 77 | if tree: 78 | for link in frozenset([str(href) for href in tree.xpath("//a/@href")]): 79 | # check if the link 1) is to github.com / bitbucket.org AND 2) somewhat 80 | # contains the project name 81 | if ("github.com" in link or "bitbucket.org" in link or 82 | "sourceforge.net" in link) \ 83 | and contains_project_name(name, link): 84 | link = validate_url(validate_repo_url(url=link)) 85 | if link: 86 | logger.debug("Found repo URL {}".format(link)) 87 | yield link 88 | except (ConnectionError, RequestException): 89 | # we really don't care about connection errors here. 90 | # A lot of project pages are simply down because the project is no longer maintained 91 | pass 92 | except etree.XMLSyntaxError: 93 | # unable to parse HTML 94 | pass 95 | except UnicodeEncodeError: 96 | pass 97 | 98 | 99 | # changelogs come in all forms and colors. This set contains most of them, e.g. (HISTORY, history, 100 | # History.md, HISTORY.rst ... etc.) 101 | CHANGELOG_FILENAME_CANDIDATES = frozenset([ 102 | item for sublist in [ 103 | [f + e, f.upper() + e, f.capitalize() + e] for f in [ 104 | "history", "news", "releases", "release", "changes", 105 | "changelog", "log" 106 | ] for e in [ 107 | "", ".txt", ".md", ".rst", ".adoc" 108 | ] 109 | ] for item in sublist 110 | ] + ["ReleaseNotes.wiki"]) 111 | 112 | DOCS_CANDIDATES = frozenset([ 113 | "docs", "doc", "documentation", "docs-src", "wiki", 114 | "docs/", "doc/", "documentation/", "docs-src/", "wiki/" 115 | ]) 116 | 117 | 118 | def find_changelog(session, repo_url, deep=True): 119 | """ 120 | Tries to find changelogs on the given `repo_url`. 121 | :param session: requests Session instance 122 | :param repo_url: str, URL to the repo 123 | :param deep: bool, deep search 124 | :return: str, URL to the raw changelog content 125 | """ 126 | logger.debug("Trying to find changelog on repo {}".format(repo_url)) 127 | resp = session.get(repo_url) 128 | if resp.status_code == 200: 129 | # build up a list of URLs on this repo. xpath() isn't returning raw strings, so we have to 130 | # convert them first. We also need to strip out all GET parameters if any. 131 | tree = etree.HTML(resp.content) 132 | try: 133 | links = frozenset([str(href).split("?")[0] for href in tree.xpath("//a/@href")]) 134 | except UnicodeEncodeError: 135 | links = [] 136 | match, found = False, False 137 | for link in links: 138 | # we are going to check for valid changelog links on the root first. We do that by 139 | # checking if the link ends with one of out changelog filename candidates. 140 | for candidate in CHANGELOG_FILENAME_CANDIDATES: 141 | if link.endswith(candidate): 142 | if "github.com" in repo_url and "blob" in link: 143 | link = link.replace(repo_url, "") 144 | match = validate_url("https://raw.githubusercontent.com" + 145 | link.replace("/blob/", "/")) 146 | elif "bitbucket.org" in repo_url and "src" in link: 147 | match = validate_url("https://bitbucket.org" + 148 | link.replace("/src/", "/raw/")) 149 | elif "sourceforge.net" in repo_url: 150 | match = validate_url(repo_url + link + "?format=raw") 151 | if match: 152 | yield match 153 | match, found = False, True 154 | 155 | # if this is a deep search and we haven't found any changelogs on the repo root, we are 156 | # going to check every potential doc page. 157 | if deep and not found: 158 | for link in links: 159 | sublink = False 160 | for doc_candidate in DOCS_CANDIDATES: 161 | if link.endswith(doc_candidate): 162 | if "github.com" in repo_url and "tree" in link: 163 | if link.startswith("https://github.com"): 164 | sublink = link 165 | else: 166 | sublink = "https://github.com" + link 167 | elif "bitbucket.org" in repo_url and "src" in link: 168 | sublink = "https://bitbucket.org" + link 169 | # if we find a valid link to a doc subdirectory on the repo call this 170 | # function again and yield all possible changelog hits 171 | if sublink: 172 | for _url in find_changelog(session, sublink, deep=False): 173 | yield _url 174 | sublink = False 175 | 176 | 177 | def find_release_page(session, repo_url): 178 | if "github.com" in repo_url: 179 | logger.debug("Unable to find changelog on {}, try release page".format(repo_url)) 180 | try: 181 | username, reponame = repo_url.split("/")[3:5] 182 | # try to fetch the release page. if it 200s, yield the release page 183 | # api URL for further processing 184 | resp = session.get("https://github.com/{username}/{reponame}/releases".format( 185 | username=username, reponame=reponame 186 | )) 187 | if resp.status_code == 200: 188 | yield "https://api.github.com/repos/{username}/{reponame}/releases".format( 189 | username=username, reponame=reponame 190 | ) 191 | except IndexError: 192 | logger.debug("Unable to construct releases url for {}".format(repo_url)) 193 | 194 | 195 | def filter_repo_urls(candidates): 196 | """ 197 | Filters down a list of URL candidates 198 | :param candidates: list, URL candidates 199 | :return: set, Repo URLs 200 | """ 201 | # first, we are going to filter down the URL candidates to be all valid urls 202 | candidates = set(url for url in [validate_url(_url) for _url in candidates] if url) 203 | logger.info("Got repo candidates {}".format(candidates)) 204 | repos = set(url for url in [validate_repo_url(_url) for _url in candidates] if url) 205 | logger.info("Filtered initial candidates down to {}".format(repos)) 206 | 207 | return repos 208 | 209 | 210 | def find_changelogs(session, name, candidates): 211 | """ 212 | Tries to find changelogs on the given URL candidates 213 | :param session: requests Session instance 214 | :param name: str, project name 215 | :param candidates: list, URL candidates 216 | :return: tuple, (set(changelog URLs), set(repo URLs)) 217 | """ 218 | repos = filter_repo_urls(candidates=candidates) 219 | # if we are lucky and there isn't a valid repo URL in our URL candidates, we need to go deeper 220 | # and check the URLs if they contain a link to a repo 221 | if not repos: 222 | logger.info("No repo found, trying to find one on related sites {}".format(candidates)) 223 | repos = set(find_repo_urls(session, name, candidates)) 224 | 225 | urls = [] 226 | for repo in repos: 227 | for url in find_changelog(session, repo): 228 | if not contains_project_name(name, url): 229 | logger.debug("Found changelog on {url}, but it does not contain the project name " 230 | "{name}, ""aborting".format(name=name, url=url)) 231 | continue 232 | urls.append(url) 233 | 234 | if not urls: 235 | # at this point we failed to fetch a changelog from plain files. we might find one on the 236 | # github release page. 237 | logger.debug("No plain changelog urls found, trying release page") 238 | for repo in repos: 239 | # make sure the link to the release page contains the project name 240 | if contains_project_name(name, repo): 241 | for url in find_release_page(session, repo): 242 | urls.append(url) 243 | return set(urls), repos 244 | 245 | 246 | def find_git_repo(session, name, candidates): 247 | """ 248 | Tries to find git repos on the given URL candidates 249 | :param session: requests Session instance 250 | :param name: str, project name 251 | :param candidates: list, URL candidates 252 | :return: tuple, (set(git URLs), set(repo URLs)) 253 | """ 254 | 255 | repos = filter_repo_urls(candidates=candidates) 256 | 257 | # if we are lucky and there isn't a valid repo URL in our URL candidates, we need to go deeper 258 | # and check the URLs if they contain a link to a repo 259 | if not repos: 260 | logger.info("No repo found, trying to find one on related sites {}".format(candidates)) 261 | repos = set(find_repo_urls(session, name, candidates)) 262 | 263 | urls = [] 264 | for repo in repos: 265 | username, reponame = repo.split("/")[3:5] 266 | if "github.com" in repo: 267 | urls.append( 268 | "https://github.com/{username}/{reponame}.git".format( 269 | username=username, reponame=reponame 270 | ) 271 | ) 272 | elif "bitbucket.org" in repo: 273 | urls.append( 274 | "https://bitbucket.org/{username}/{reponame}".format( 275 | username=username, reponame=reponame 276 | ) 277 | ) 278 | return set(urls), repos 279 | -------------------------------------------------------------------------------- /changelogs/launchpad.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | 4 | 5 | def get_metadata(session, name): 6 | """ 7 | Gets meta data from launchpad for the given package. 8 | :param session: requests Session instance 9 | :param name: str, package 10 | :return: dict, meta data 11 | """ 12 | resp = session.get( 13 | "https://api.launchpad.net/1.0/{}/releases".format(name)) 14 | if resp.status_code == 200: 15 | return resp.json() 16 | return {} 17 | 18 | 19 | def get_releases(data, **kwargs): 20 | """ 21 | Gets all releases from pypi meta data. 22 | :param data: dict, meta data 23 | :return: list, str releases 24 | """ 25 | if "entries" in data: 26 | return [e["version"] for e in data["entries"]] 27 | return [] 28 | 29 | 30 | def find_changelogs(session, name, candidates): 31 | """ 32 | Tries to find changelogs on the given URL candidates 33 | :param session: requests Session instance 34 | :param name: str, project name 35 | :param candidates: list, URL candidates 36 | :return: tuple, (set(changelog URLs), set(repo URLs)) 37 | """ 38 | return set(), set() 39 | 40 | 41 | def get_urls(session, name, data, find_changelogs_fn, **kwargs): 42 | """ 43 | Gets URLs to changelogs. 44 | :param session: requests Session instance 45 | :param name: str, package name 46 | :param data: dict, meta data 47 | :param find_changelogs_fn: function, find_changelogs 48 | :return: tuple, (set(changelog URLs), set(repo URLs)) 49 | """ 50 | # if this package has valid meta data, build up a list of URL candidates we can possibly 51 | # search for changelogs on 52 | return {"https://api.launchpad.net/1.0/{}/releases".format(name)}, set() 53 | 54 | 55 | def get_content(session, urls, chars_limit): 56 | """ 57 | Loads the content from URLs, ignoring connection errors. 58 | :param session: requests Session instance 59 | :param urls: list, str URLs 60 | :param chars_limit: int, changelog content entry chars limit 61 | :return: str, content 62 | """ 63 | for url in urls: 64 | with session.get(url, stream=True) as resp: 65 | # Avoid parsing if exceeds the limit as slicing would break the Json 66 | if resp.ok and int(resp.headers['content-length']) < chars_limit: 67 | return resp.json() 68 | return {} 69 | 70 | 71 | def parse(name, content, releases, get_head_fn): 72 | """ 73 | Parses the given content for a valid changelog 74 | :param name: str, package name 75 | :param content: str, content 76 | :param releases: list, releases 77 | :param get_head_fn: function 78 | :return: dict, changelog 79 | """ 80 | try: 81 | return {e["version"]: e["changelog"] for e in content["entries"] 82 | if e["changelog"]} 83 | except KeyError: 84 | return {} 85 | -------------------------------------------------------------------------------- /changelogs/npm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | from packaging.version import parse 4 | 5 | 6 | def get_metadata(session, name): 7 | """ 8 | Gets meta data from pypi for the given package. 9 | :param session: requests Session instance 10 | :param name: str, package 11 | :return: dict, meta data 12 | """ 13 | resp = session.get("https://registry.npmjs.org/{}".format(name)) 14 | if resp.status_code == 200: 15 | return resp.json() 16 | return {} 17 | 18 | 19 | def get_releases(data, **kwargs): 20 | """ 21 | Gets all releases from pypi meta data. 22 | :param data: dict, meta data 23 | :return: list, str releases 24 | """ 25 | if "versions" in data: 26 | return sorted(data["versions"].keys(), key=lambda v: parse(v), reverse=True) 27 | return [] 28 | 29 | 30 | def get_urls(session, name, data, find_changelogs_fn, **kwargs): 31 | """ 32 | Gets URLs to changelogs. 33 | :param session: requests Session instance 34 | :param name: str, package name 35 | :param data: dict, meta data 36 | :param find_changelogs_fn: function, find_changelogs 37 | :return: tuple, (set(changelog URLs), set(repo URLs)) 38 | """ 39 | # if this package has valid meta data, build up a list of URL candidates we can possibly 40 | # search for changelogs on 41 | if "versions" in data: 42 | candidates = set() 43 | for version, item in data["versions"].items(): 44 | if "homepage" in item and item["homepage"] is not None: 45 | if isinstance(item["homepage"], list): 46 | candidates.add(*item["homepage"]) 47 | else: 48 | candidates.add(item["homepage"]) 49 | if "repository" in item and item["repository"] is not None: 50 | if "url" in item["repository"]: 51 | repo = item["repository"]["url"] 52 | elif "path" in item["repository"]: 53 | repo = item["repository"]["path"] 54 | else: 55 | continue 56 | repo = repo.replace("git://", "https://").replace(".git", "") 57 | candidates.add(repo) 58 | return find_changelogs_fn(session=session, name=name, candidates=candidates) 59 | return set(), set() 60 | -------------------------------------------------------------------------------- /changelogs/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | from packaging.version import Version, InvalidVersion 4 | from gitchangelog.gitchangelog import changelog, GitRepos 5 | import subprocess 6 | import shutil 7 | 8 | INVALID_LINE_START = frozenset(["-", "*", " ", "\t", "