├── .bumpversion.cfg ├── .changes.toml ├── .circleci └── config.yml ├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .scrutinizer.yml ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── TODO.md ├── appveyor.yml ├── changes ├── __init__.py ├── __main__.py ├── attributes.py ├── changelog.py ├── cli.py ├── commands │ ├── __init__.py │ ├── publish.py │ ├── stage.py │ └── status.py ├── compat.py ├── config.py ├── exceptions.py ├── flow.py ├── models │ ├── __init__.py │ └── repository.py ├── packaging.py ├── probe.py ├── prompt.py ├── services.py ├── shell.py ├── templates │ └── release_notes_template.md ├── util.py ├── vcs.py ├── venv.py ├── verification.py └── version.py ├── docs ├── _snippets │ ├── README.md │ └── shoulders.md ├── design.md ├── index.md ├── media └── tests.md ├── media ├── changes.png ├── demo.svg └── favicon.ico ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── test_attributes.py ├── test_changelog.py ├── test_cli.py ├── test_config.py ├── test_init.py ├── test_packaging.py ├── test_probe.py ├── test_publish.py ├── test_repository.py ├── test_shell.py ├── test_stage.py ├── test_status.py ├── test_util.py ├── test_vcs.py └── test_version.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.7.0 3 | 4 | [bumpversion:file:pyproject.toml] 5 | [bumpversion:file:changes/__init__.py] -------------------------------------------------------------------------------- /.changes.toml: -------------------------------------------------------------------------------- 1 | [changes] 2 | releases_directory = "docs/releases" 3 | [changes.labels.docs] 4 | id = 153027109 5 | url = "https://api.github.com/repos/michaeljoseph/changes/labels/docs" 6 | name = "docs" 7 | color = "0052cc" 8 | default = false 9 | description = "Docs" 10 | [changes.labels.bug] 11 | id = 52048163 12 | url = "https://api.github.com/repos/michaeljoseph/changes/labels/bug" 13 | name = "bug" 14 | color = "fc2929" 15 | default = true 16 | description = "Bugs" 17 | [changes.labels.enhancement] 18 | id = 52048165 19 | url = "https://api.github.com/repos/michaeljoseph/changes/labels/enhancement" 20 | name = "enhancement" 21 | color = "84b6eb" 22 | default = true 23 | description = "Enhancements" 24 | [changes.labels.chore] 25 | id = 721722843 26 | url = "https://api.github.com/repos/michaeljoseph/changes/labels/chore" 27 | name = "chore" 28 | color = "5319e7" 29 | default = false 30 | description = "Chores" 31 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # https://circleci.com/developer/orbs/orb/circleci/python 2 | version: 2.1 3 | orbs: 4 | python: circleci/python@1.4.0 5 | jobs: 6 | test: 7 | executor: python/default 8 | steps: 9 | - checkout 10 | - python/install-packages: 11 | pkg-manager: pip 12 | - run: tox 13 | workflows: 14 | main: 15 | jobs: 16 | - test -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_up 2 | dotenv 3 | use asdf 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '38 8 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'python' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v2 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v1 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test, Lint and Document 2 | on: 3 | push: 4 | branches: [ main ] 5 | tags: [ "*" ] 6 | pull_request: 7 | workflow_dispatch: 8 | jobs: 9 | tests-and-lints: 10 | name: Test and Lint ${{ matrix.python-version }} on ${{ matrix.platform }} 11 | runs-on: ${{ matrix.platform }} 12 | strategy: 13 | matrix: 14 | platform: [ubuntu-latest, macos-latest, windows-latest] 15 | python-version: [3.7, 3.8, 3.9] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Lint and run tests with Python ${{ matrix.python-version }} on ${{ matrix.platform }} 22 | env: 23 | PLATFORM: ${{ matrix.platform }} 24 | run: | 25 | python -m pip install --upgrade pip tox tox-gh-actions 26 | tox -e lint,test,report-coverage 27 | 28 | - name: Upload test results 29 | uses: actions/upload-artifact@v2 30 | with: 31 | name: test-results-${{ matrix.python-version }}-${{ matrix.platform }} 32 | path: | 33 | test-reports/junit.xml 34 | test-reports/cobertura.xml 35 | test-reports/coverage_html 36 | if: ${{ always() }} 37 | 38 | documents: 39 | name: "Build and deploy documentation" 40 | needs: tests-and-lints 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-python@v2 45 | with: 46 | python-version: "3.8" 47 | 48 | - name: Build documentation 49 | run: | 50 | python -m pip install --upgrade pip tox 51 | tox -e docs 52 | 53 | - name: Download test report artifacts into doc site 54 | uses: actions/download-artifact@v2 55 | with: 56 | name: test-results-3.9-macos-latest 57 | path: site 58 | 59 | - name: Upload the generated documentation site (TODO for packaging) 60 | uses: actions/upload-artifact@v2 61 | with: 62 | name: changes-docs-${{ github.event.number }} 63 | path: site/ 64 | 65 | - name: Deploy PR preview to Surge 66 | uses: afc163/surge-preview@v1 67 | id: preview_step 68 | if: github.event_name == 'pull_request' 69 | with: 70 | surge_token: ${{ secrets.SURGE_TOKEN }} 71 | github_token: ${{ secrets.GITHUB_TOKEN }} 72 | dist: site 73 | teardown: 'true' 74 | build: | 75 | echo "mkdocs has built site/ already" 76 | - name: Get the preview_url 77 | run: echo "${{ steps.preview_step.outputs.preview_url }}" 78 | 79 | - name: Deploy to GitHub Pages 80 | uses: peaceiris/actions-gh-pages@v3 81 | if: github.ref == 'refs/heads/main' 82 | with: 83 | github_token: ${{ secrets.GITHUB_TOKEN }} 84 | publish_dir: ./site 85 | 86 | - name: Deploy to Surge 87 | if: github.ref == 'refs/heads/main' 88 | run: | 89 | npx surge site changes.michaeljoseph.surge.sh 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage* 26 | .tox 27 | nosetests.xml 28 | /.travis-solo 29 | .pytest* 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | /site 40 | /docs/_build 41 | .idea 42 | .vscode 43 | *.iml 44 | .noseids 45 | .ropeproject 46 | 47 | # Virtual environments 48 | venv/ 49 | .cache 50 | 51 | cache.sqlite 52 | test-reports/ 53 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.0.1 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: mixed-line-ending 8 | - id: check-byte-order-marker 9 | - id: check-merge-conflict 10 | - repo: https://gitlab.com/pycqa/flake8 11 | rev: 3.9.2 12 | hooks: 13 | - id: flake8 14 | additional_dependencies: [flake8-isort,flake8-black] 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | mkdocs: 3 | configuration: mkdocs.yml 4 | fail_on_warning: true 5 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | python: 3 | code_rating: true 4 | duplicate_code: true 5 | format_bad_indentation: 6 | indentation: '4 spaces' 7 | format_mixed_indentation: true 8 | format_line_too_long: 9 | max_length: '79' 10 | imports_relative_import: true 11 | imports_wildcard_import: true 12 | format_bad_whitespace: true 13 | format_multiple_statements: true 14 | basic_invalid_name: 15 | functions: '[a-z_][a-z0-9_]{2,30}$' 16 | variables: '[a-z_][a-z0-9_]{2,30}$' 17 | whitelisted_names: 'i,j,k,ex,Run,_' 18 | constants: '(([A-Z_][A-Z0-9_]*)|(__.*__))$' 19 | attributes: '[a-z_][a-z0-9_]{2,30}$' 20 | arguments: '[a-z_][a-z0-9_]{2,30}$' 21 | class_attributes: '([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$' 22 | inline_vars: '[A-Za-z_][A-Za-z0-9_]*$' 23 | classes: '[A-Z_][a-zA-Z0-9]+$' 24 | modules: '(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$' 25 | methods: '[a-z_][a-z0-9_]{2,30}$' 26 | classes_no_self_argument: true 27 | classes_bad_mcs_method_argument: true 28 | classes_bad_classmethod_argument: true 29 | variables_used_before_assignment: true 30 | variables_unpacking_non_sequence: true 31 | variables_undefined_variable: true 32 | variables_undefined_loop_variable: true 33 | variables_undefined_all_variable: true 34 | variables_redefine_in_handler: true 35 | variables_no_name_in_module: true 36 | variables_global_variable_undefined: true 37 | variables_redefined_outer_name: true 38 | typecheck_not_callable: true 39 | variables_unbalanced_tuple_unpacking: true 40 | typecheck_unexpected_keyword_arg: true 41 | typecheck_no_value_for_parameter: true 42 | typecheck_no_member: true 43 | typecheck_missing_kwoa: true 44 | typecheck_maybe_no_member: true 45 | typecheck_duplicate_keyword_arg: true 46 | typecheck_too_many_function_args: true 47 | typecheck_assignment_from_none: true 48 | typecheck_assignment_from_no_return: true 49 | typecheck_redundant_keyword_arg: true 50 | string_unused_format_string_key: true 51 | string_truncated_format_string: true 52 | string_too_many_format_args: true 53 | string_too_few_format_args: true 54 | string_mixed_format_string: true 55 | string_missing_format_string_key: true 56 | string_format_needs_mapping: true 57 | string_constant_anomalous_unicode_escape_in_string: true 58 | string_constant_anomalous_backslash_in_string: true 59 | string_bad_str_strip_call: true 60 | string_bad_format_string_key: true 61 | open_mode_bad_open_mode: true 62 | string_bad_format_character: true 63 | logging_unsupported_format: true 64 | logging_too_few_args: true 65 | logging_too_many_args: true 66 | logging_format_truncated: true 67 | imports_reimported: true 68 | imports_import_self: true 69 | exceptions_raising_string: true 70 | exceptions_raising_non_exception: true 71 | imports_import_error: true 72 | exceptions_raising_bad_type: true 73 | exceptions_notimplemented_raised: true 74 | exceptions_catching_non_exception: true 75 | exceptions_bad_except_order: true 76 | classes_valid_slots: true 77 | classes_signature_differs: true 78 | classes_non_iterator_returned: true 79 | classes_no_method_argument: true 80 | classes_missing_interface_method: true 81 | classes_interface_is_not_class: true 82 | classes_arguments_differ: true 83 | classes_access_member_before_definition: true 84 | classes_abstract_method: true 85 | basic_yield_outside_function: true 86 | basic_not_in_loop: true 87 | basic_nonexistent_operator: true 88 | basic_missing_reversed_argument: true 89 | basic_missing_module_attribute: true 90 | basic_lost_exception: true 91 | basic_duplicate_argument_name: true 92 | basic_bad_reversed_sequence: true 93 | basic_assert_on_tuple: true 94 | basic_abstract_class_instantiated: true 95 | variables_invalid_all_object: true 96 | imports_cyclic_import: true 97 | classes_bad_staticmethod_argument: true 98 | classes_bad_context_manager: true 99 | basic_return_outside_function: true 100 | basic_return_in_init: true 101 | basic_return_arg_in_generator: true 102 | basic_init_is_generator: true 103 | basic_duplicate_key: true 104 | 105 | filter: 106 | excluded_paths: 107 | - '*/test/*' 108 | 109 | tools: 110 | external_code_coverage: 111 | runs: 3 112 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Lead 4 | 5 | * [Michael Joseph](https://github.com/michaeljoseph) 6 | 7 | ## Contributors 8 | 9 | * [Chris Modjeska](https://github.com/Sureiya) 10 | 11 | * [Peter Goldsborough](https://github.com/goldsborough) 12 | 13 | * [K.-Michael Aye](https://github.com/michaelaye) 14 | 15 | * [Conor Ryan](https://github.com/getconor) 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Changelog](https://github.com/michaeljoseph/changes/releases) 2 | 3 | ## [0.7.0](https://github.com/michaeljoseph/changes/compare/0.6.0...0.7.0) 4 | 5 | * [041da30](https://github.com/michaeljoseph/changes/commit/041da30) Re-order test-runners 6 | * [aa98503](https://github.com/michaeljoseph/changes/commit/aa98503) Test config 7 | * [089c710](https://github.com/michaeljoseph/changes/commit/089c710) Use click.prompt 8 | * [33b5e21](https://github.com/michaeljoseph/changes/commit/33b5e21) Add github releases to publishing 9 | * [ead077d](https://github.com/michaeljoseph/changes/commit/ead077d) Store initial settings 10 | * [3487428](https://github.com/michaeljoseph/changes/commit/3487428) Rm dupes 11 | * [80a31f5](https://github.com/michaeljoseph/changes/commit/80a31f5) Add store_settings 12 | * [2285f08](https://github.com/michaeljoseph/changes/commit/2285f08) Cleanup and reorganise 13 | * [f457cf5](https://github.com/michaeljoseph/changes/commit/f457cf5) Store additional context 14 | * [facba40](https://github.com/michaeljoseph/changes/commit/facba40) Create github releases 15 | * [1fb7910](https://github.com/michaeljoseph/changes/commit/1fb7910) Return a list of distributions 16 | * [2a471bf](https://github.com/michaeljoseph/changes/commit/2a471bf) Rename to build_distributions 17 | * [41f75ee](https://github.com/michaeljoseph/changes/commit/41f75ee) Fix bad partial commit 18 | * [5c5c5c3](https://github.com/michaeljoseph/changes/commit/5c5c5c3) Update probe tests 19 | * [23d2176](https://github.com/michaeljoseph/changes/commit/23d2176) Lint 20 | * [f713e4b](https://github.com/michaeljoseph/changes/commit/f713e4b) Drop github and requirements.txt requirements 21 | * [779906f](https://github.com/michaeljoseph/changes/commit/779906f) Test parsed git remote 22 | * [41e7388](https://github.com/michaeljoseph/changes/commit/41e7388) Initialise test repo 23 | * [eb39606](https://github.com/michaeljoseph/changes/commit/eb39606) Add giturlparse and parse remote 24 | * [e1828ba](https://github.com/michaeljoseph/changes/commit/e1828ba) -26, +34 on travis 25 | * [b6d9acd](https://github.com/michaeljoseph/changes/commit/b6d9acd) Future 26 | * [2c5684b](https://github.com/michaeljoseph/changes/commit/2c5684b) -py26, +py34 27 | * [94ca0de](https://github.com/michaeljoseph/changes/commit/94ca0de) Rm unittest2 vestiges 28 | * [ef5c25c](https://github.com/michaeljoseph/changes/commit/ef5c25c) Ping gitter on travis events 29 | * [71d1fbc](https://github.com/michaeljoseph/changes/commit/71d1fbc) Upgrade click for `click.open_file` 30 | * [41b9708](https://github.com/michaeljoseph/changes/commit/41b9708) Add and test loading project configuration 31 | * [8e75c7c](https://github.com/michaeljoseph/changes/commit/8e75c7c) Rename `config.Changes` to `config.CLI` 32 | * [f7952a7](https://github.com/michaeljoseph/changes/commit/f7952a7) Add codecov badge 33 | * [eeb206d](https://github.com/michaeljoseph/changes/commit/eeb206d) codecov.io 34 | * [b003f7d](https://github.com/michaeljoseph/changes/commit/b003f7d) Add landscape.io health badge 35 | * [32722c1](https://github.com/michaeljoseph/changes/commit/32722c1) Add version option 36 | * [7b752e0](https://github.com/michaeljoseph/changes/commit/7b752e0) Added Gitter badge 37 | * [e4e9216](https://github.com/michaeljoseph/changes/commit/e4e9216) Rm spurious vcs quotes 38 | * [443153c](https://github.com/michaeljoseph/changes/commit/443153c) Ignore errors 39 | * [af3c840](https://github.com/michaeljoseph/changes/commit/af3c840) Ensure there are no stale dists 40 | * [6482c6e](https://github.com/michaeljoseph/changes/commit/6482c6e) Pass twine a list of filepaths 41 | * [da5da35](https://github.com/michaeljoseph/changes/commit/da5da35) Packaging dry run tests 42 | * [ab06fa4](https://github.com/michaeljoseph/changes/commit/ab06fa4) Correct build_package return bools and dry run logging 43 | * [8e22273](https://github.com/michaeljoseph/changes/commit/8e22273) Switch to twine for uploading 44 | * [94598bf](https://github.com/michaeljoseph/changes/commit/94598bf) Explicitly package in the release command 45 | * [48fada0](https://github.com/michaeljoseph/changes/commit/48fada0) Add a corresponding build cli subcommand 46 | * [04e1f3f](https://github.com/michaeljoseph/changes/commit/04e1f3f) Extract build_package step 47 | * [8bc57ff](https://github.com/michaeljoseph/changes/commit/8bc57ff) Twine is a runtime requirement 48 | * [fe356f1](https://github.com/michaeljoseph/changes/commit/fe356f1) Ignore all coverage files 49 | * [710a4df](https://github.com/michaeljoseph/changes/commit/710a4df) Add twine 50 | * [0dfb963](https://github.com/michaeljoseph/changes/commit/0dfb963) Run tests first 51 | * [3d88ccc](https://github.com/michaeljoseph/changes/commit/3d88ccc) Commit and tag after the package has been built and uploaded 52 | * [865c089](https://github.com/michaeljoseph/changes/commit/865c089) Break out each check for better output granularity 53 | * [9e3c804](https://github.com/michaeljoseph/changes/commit/9e3c804) Changelog fix 54 | 55 | ## [0.6.0](https://github.com/michaeljoseph/changes/compare/0.5.0...0.6.0) 56 | 57 | * [9fbb6fe](https://github.com/michaeljoseph/changes/commit/9fbb6fe) Add wheel 58 | * [4fdcf03](https://github.com/michaeljoseph/changes/commit/4fdcf03) Add a list to a list 59 | * [e21ffe5](https://github.com/michaeljoseph/changes/commit/e21ffe5) Fix 0.5.0 changelog entry 60 | * [c3d75d4](https://github.com/michaeljoseph/changes/commit/c3d75d4) Add pytest options 61 | * [ccffd94](https://github.com/michaeljoseph/changes/commit/ccffd94) Actually bring config back 62 | * [07c9c07](https://github.com/michaeljoseph/changes/commit/07c9c07) Bring back config 63 | * [9d8232c](https://github.com/michaeljoseph/changes/commit/9d8232c) Config repytest conversion completion 64 | * [7a26f35](https://github.com/michaeljoseph/changes/commit/7a26f35) Rm config references 65 | * [69e9ab2](https://github.com/michaeljoseph/changes/commit/69e9ab2) Flatten nosetests with pytest 66 | * [db77edb](https://github.com/michaeljoseph/changes/commit/db77edb) Watch changes and test directories 67 | * [709de66](https://github.com/michaeljoseph/changes/commit/709de66) Hardcode changelog filename and rm config module 68 | * [8757baf](https://github.com/michaeljoseph/changes/commit/8757baf) Don't overwrite the log command 69 | * [dc1be57](https://github.com/michaeljoseph/changes/commit/dc1be57) Docs and comments 70 | * [36a1d1c](https://github.com/michaeljoseph/changes/commit/36a1d1c) Drop py3 and switch to py.test 71 | * [17db8d4](https://github.com/michaeljoseph/changes/commit/17db8d4) The git log command has already been split 72 | * [2e41996](https://github.com/michaeljoseph/changes/commit/2e41996) Massive cli refactor 73 | * [9723078](https://github.com/michaeljoseph/changes/commit/9723078) Port tag 74 | * [37170d6](https://github.com/michaeljoseph/changes/commit/37170d6) Run all the tests 75 | * [3256999](https://github.com/michaeljoseph/changes/commit/3256999) Port packaging commands 76 | * [b713d07](https://github.com/michaeljoseph/changes/commit/b713d07) Include requirements in package 77 | * [9d06f80](https://github.com/michaeljoseph/changes/commit/9d06f80) Port bump_version 78 | * [46aa205](https://github.com/michaeljoseph/changes/commit/46aa205) Refactor probe to use click context 79 | * [1798448](https://github.com/michaeljoseph/changes/commit/1798448) Test main changelog routine 80 | * [d9a8fae](https://github.com/michaeljoseph/changes/commit/d9a8fae) Port changelog command from docopt to click 81 | * [97a0449](https://github.com/michaeljoseph/changes/commit/97a0449) nose => py.test 82 | * [0bd827c](https://github.com/michaeljoseph/changes/commit/0bd827c) We build wheels 83 | * [abb1641](https://github.com/michaeljoseph/changes/commit/abb1641) Reinstate tests 84 | * [64f436d](https://github.com/michaeljoseph/changes/commit/64f436d) Aliases 85 | * [6139c07](https://github.com/michaeljoseph/changes/commit/6139c07) Refactor release command to flow module 86 | * [7c8d9bc](https://github.com/michaeljoseph/changes/commit/7c8d9bc) Improve README 87 | * [4b06b28](https://github.com/michaeljoseph/changes/commit/4b06b28) fabric => plumbum 88 | * [70a1e2d](https://github.com/michaeljoseph/changes/commit/70a1e2d) Link dev requirements to top level 89 | * [1733921](https://github.com/michaeljoseph/changes/commit/1733921) Use io.open and read runtime requirements 90 | * [dde9538](https://github.com/michaeljoseph/changes/commit/dde9538) Coverage for all python version runs 91 | * [fc37056](https://github.com/michaeljoseph/changes/commit/fc37056) Send coverage results to scrutinizer-ci 92 | * [a5c3922](https://github.com/michaeljoseph/changes/commit/a5c3922) Add scrutinizer-ci badge 93 | 94 | ## [0.5.0](https://github.com/michaeljoseph/changes/compare/0.4.0...0.5.0) 95 | 96 | * [8bb96b4](https://github.com/michaeljoseph/changes/commit/8bb96b4) 0.5.0 97 | * [6ffabf2](https://github.com/michaeljoseph/changes/commit/6ffabf2) Handle dry_run result appropriately 98 | * [14a2437](https://github.com/michaeljoseph/changes/commit/14a2437) Raise if failed 99 | * [12d674b](https://github.com/michaeljoseph/changes/commit/12d674b) Premature exceptions 100 | * [52a9d2c](https://github.com/michaeljoseph/changes/commit/52a9d2c) s/sh/fabric.api.local/g 101 | * [6f306b6](https://github.com/michaeljoseph/changes/commit/6f306b6) Rm sh 102 | * [7f514a0](https://github.com/michaeljoseph/changes/commit/7f514a0) Improve error handling 103 | * [f576c02](https://github.com/michaeljoseph/changes/commit/f576c02) Fix tests 104 | * [ada17f5](https://github.com/michaeljoseph/changes/commit/ada17f5) Use fabric to create a tmp venv and install 105 | * [57d341f](https://github.com/michaeljoseph/changes/commit/57d341f) Install fabric, rm virtualenv 106 | * [7252d5a](https://github.com/michaeljoseph/changes/commit/7252d5a) Thanks @brendon9x 107 | * [74a6405](https://github.com/michaeljoseph/changes/commit/74a6405) Linting 108 | * [d51d9b1](https://github.com/michaeljoseph/changes/commit/d51d9b1) Rework virtualenv handling 109 | * [ca8b607](https://github.com/michaeljoseph/changes/commit/ca8b607) Add missing venv module 110 | * [742c723](https://github.com/michaeljoseph/changes/commit/742c723) virtualenv-api 111 | * [2819307](https://github.com/michaeljoseph/changes/commit/2819307) Ignore rope 112 | * [a1ea585](https://github.com/michaeljoseph/changes/commit/a1ea585) Spurious 113 | * [8128ebc](https://github.com/michaeljoseph/changes/commit/8128ebc) Skip failing tests (for now) (those are not famous last words) 114 | * [2cc181b](https://github.com/michaeljoseph/changes/commit/2cc181b) Revert "Snuggle requirements" 115 | * [a4e4993](https://github.com/michaeljoseph/changes/commit/a4e4993) Fix git log formatting 116 | * [a20c3f8](https://github.com/michaeljoseph/changes/commit/a20c3f8) Snuggle requirements 117 | * [80a3da5](https://github.com/michaeljoseph/changes/commit/80a3da5) Ignore travis-solo 118 | * [085e02b](https://github.com/michaeljoseph/changes/commit/085e02b) Update upload command docs to include wheels 119 | * [21dbae8](https://github.com/michaeljoseph/changes/commit/21dbae8) Fix markup 120 | * [2d02bd6](https://github.com/michaeljoseph/changes/commit/2d02bd6) Handle initial release with exceptions 121 | 122 | ## [0.4.0](https://github.com/michaeljoseph/changes/compare/0.3.0...0.4.0) 123 | 124 | * [8796606](https://github.com/michaeljoseph/changes/commit/8796606) Discard sh intermediates 125 | * [9df6e19](https://github.com/michaeljoseph/changes/commit/9df6e19) Rm debug logging 126 | * [f3c3fad](https://github.com/michaeljoseph/changes/commit/f3c3fad) Revert "0.4.0" 127 | * [7beb4ee](https://github.com/michaeljoseph/changes/commit/7beb4ee) 0.4.0 128 | * [bb356d7](https://github.com/michaeljoseph/changes/commit/bb356d7) We don't need the result, sh will raise 129 | * [0cd1d9a](https://github.com/michaeljoseph/changes/commit/0cd1d9a) Generate wheels if the project requires it 130 | * [4a207e3](https://github.com/michaeljoseph/changes/commit/4a207e3) Test has_requirement 131 | * [f848963](https://github.com/michaeljoseph/changes/commit/f848963) Refactor requirements handling 132 | * [dc76567](https://github.com/michaeljoseph/changes/commit/dc76567) Require wheel 133 | * [beffa15](https://github.com/michaeljoseph/changes/commit/beffa15) s/module/package 134 | * [4c68326](https://github.com/michaeljoseph/changes/commit/4c68326) Use the default 135 | * [c4333dc](https://github.com/michaeljoseph/changes/commit/c4333dc) Update readme 136 | * [8b0e4a9](https://github.com/michaeljoseph/changes/commit/8b0e4a9) Add alternate requirements option 137 | * [73323c2](https://github.com/michaeljoseph/changes/commit/73323c2) Ignore .noseids 138 | * [e42c815](https://github.com/michaeljoseph/changes/commit/e42c815) Authors! 139 | * [ef055e0](https://github.com/michaeljoseph/changes/commit/ef055e0) s/app_name/module_name/g 140 | * [b5e02d6](https://github.com/michaeljoseph/changes/commit/b5e02d6) s/module/package/ 141 | * [4be26cf](https://github.com/michaeljoseph/changes/commit/4be26cf) Fixed bug with release command 142 | * [7c59ed0](https://github.com/michaeljoseph/changes/commit/7c59ed0) Use the module name for tarball installation, if specified, with a test. 143 | * [3970e4b](https://github.com/michaeljoseph/changes/commit/3970e4b) Document package=module expectation 144 | * [67e18d0](https://github.com/michaeljoseph/changes/commit/67e18d0) autoenv 145 | * [a142e7b](https://github.com/michaeljoseph/changes/commit/a142e7b) whee! 146 | * [b8b1db4](https://github.com/michaeljoseph/changes/commit/b8b1db4) Rm pandoc 147 | * [d9e4b3c](https://github.com/michaeljoseph/changes/commit/d9e4b3c) Update noinput option description 148 | * [d958a9f](https://github.com/michaeljoseph/changes/commit/d958a9f) Stop defaulting patch to true 149 | * [79361ee](https://github.com/michaeljoseph/changes/commit/79361ee) Add noinput option 150 | * [2426764](https://github.com/michaeljoseph/changes/commit/2426764) abc 151 | * [e2e97d1](https://github.com/michaeljoseph/changes/commit/e2e97d1) Linting 152 | * [7561d38](https://github.com/michaeljoseph/changes/commit/7561d38) Dry runs 153 | * [756cd63](https://github.com/michaeljoseph/changes/commit/756cd63) Add subprocess execution for testing installation in a virtualenv 154 | * [e22b2d5](https://github.com/michaeljoseph/changes/commit/e22b2d5) Port packaging to sh 155 | * [182fb0a](https://github.com/michaeljoseph/changes/commit/182fb0a) Fix versioning 156 | * [0d45807](https://github.com/michaeljoseph/changes/commit/0d45807) Migrate probe to sh 157 | * [5b20588](https://github.com/michaeljoseph/changes/commit/5b20588) Migrate vcs to sh 158 | * [ab8bd20](https://github.com/michaeljoseph/changes/commit/ab8bd20) iterpipes => sh 159 | * [95c6b71](https://github.com/michaeljoseph/changes/commit/95c6b71) Port changelog to sh 160 | * [9abb0ca](https://github.com/michaeljoseph/changes/commit/9abb0ca) Port attributes to sh 161 | * [cf36094](https://github.com/michaeljoseph/changes/commit/cf36094) Rename testing to verification 162 | * [7d27e37](https://github.com/michaeljoseph/changes/commit/7d27e37) Wrap sh calls to check for dry_run 163 | * [0e0505b](https://github.com/michaeljoseph/changes/commit/0e0505b) Use subshell to avoid changing directories 164 | * [95d424d](https://github.com/michaeljoseph/changes/commit/95d424d) Cleanup 165 | * [17d5f9f](https://github.com/michaeljoseph/changes/commit/17d5f9f) Refactor bump_version 166 | * [bd9beae](https://github.com/michaeljoseph/changes/commit/bd9beae) Refactor packaging module 167 | * [de1741d](https://github.com/michaeljoseph/changes/commit/de1741d) Renamed run_tests command 168 | * [de3b939](https://github.com/michaeljoseph/changes/commit/de3b939) Improve attributes coverage 169 | * [6275e71](https://github.com/michaeljoseph/changes/commit/6275e71) Refactor testing module 170 | * [9fae7a5](https://github.com/michaeljoseph/changes/commit/9fae7a5) Refactor vcs functions 171 | * [91d24f6](https://github.com/michaeljoseph/changes/commit/91d24f6) Test config 172 | * [ea1454e](https://github.com/michaeljoseph/changes/commit/ea1454e) Move and generalise `extract_version_arguments` 173 | * [3243197](https://github.com/michaeljoseph/changes/commit/3243197) Correct english 174 | * [e3121c8](https://github.com/michaeljoseph/changes/commit/e3121c8) Cleanup and linting 175 | * [091f0b0](https://github.com/michaeljoseph/changes/commit/091f0b0) Refactor changelog 176 | * [12a975e](https://github.com/michaeljoseph/changes/commit/12a975e) Alias config.arguments and setup from docopt 177 | * [b797fdc](https://github.com/michaeljoseph/changes/commit/b797fdc) Add config module 178 | * [0b57bd3](https://github.com/michaeljoseph/changes/commit/0b57bd3) Renamed this function 179 | * [7f9ea0d](https://github.com/michaeljoseph/changes/commit/7f9ea0d) Use version prefix for tagging 180 | * [60cbb34](https://github.com/michaeljoseph/changes/commit/60cbb34) Lint 181 | * [434e812](https://github.com/michaeljoseph/changes/commit/434e812) Unique test case names 182 | * [2d32c57](https://github.com/michaeljoseph/changes/commit/2d32c57) Add optional version prefix option 183 | * [3fcf1e3](https://github.com/michaeljoseph/changes/commit/3fcf1e3) Install requirements first 184 | * [50df640](https://github.com/michaeljoseph/changes/commit/50df640) Correct docopt command name too 185 | * [7efc4e0](https://github.com/michaeljoseph/changes/commit/7efc4e0) Fix call to `extract_version_arguments` and test 186 | * [a4e4b15](https://github.com/michaeljoseph/changes/commit/a4e4b15) Use pandoc to convert README.md to rst for setuptools.long_description 187 | * [192232b](https://github.com/michaeljoseph/changes/commit/192232b) Fix non-ascii characters in README 188 | * [275a3be](https://github.com/michaeljoseph/changes/commit/275a3be) Require pypandoc 189 | * [349be50](https://github.com/michaeljoseph/changes/commit/349be50) Use iterpipes for 2.6 subprocess compatibility 190 | * [707adca](https://github.com/michaeljoseph/changes/commit/707adca) Alias cli.main and read metadata in setup 191 | * [4c9b7f5](https://github.com/michaeljoseph/changes/commit/4c9b7f5) Cleanup and linting 192 | * [7e31de7](https://github.com/michaeljoseph/changes/commit/7e31de7) Rename version command 193 | * [dc86ef4](https://github.com/michaeljoseph/changes/commit/dc86ef4) Refactor shell 194 | * [57e1db6](https://github.com/michaeljoseph/changes/commit/57e1db6) Refactor probe 195 | * [9606c27](https://github.com/michaeljoseph/changes/commit/9606c27) Refactor version redux 196 | * [b0c5204](https://github.com/michaeljoseph/changes/commit/b0c5204) Cleanup 197 | * [daeda05](https://github.com/michaeljoseph/changes/commit/daeda05) Refactor version 198 | * [da2f4e6](https://github.com/michaeljoseph/changes/commit/da2f4e6) Refactor project attributes 199 | * [67fe635](https://github.com/michaeljoseph/changes/commit/67fe635) Fix markup 200 | * [9b1fe29](https://github.com/michaeljoseph/changes/commit/9b1fe29) Exclude travis builds from coverage reporting 201 | * [b3bc141](https://github.com/michaeljoseph/changes/commit/b3bc141) Just output the new content 202 | * [93257b4](https://github.com/michaeljoseph/changes/commit/93257b4) Refactor execute to take a command string 203 | * [72dc308](https://github.com/michaeljoseph/changes/commit/72dc308) Use the real command over an alias that may not be configured 204 | * [9f10a34](https://github.com/michaeljoseph/changes/commit/9f10a34) Check for `changes` requirements and bail if they're not met 205 | * [784acb6](https://github.com/michaeljoseph/changes/commit/784acb6) Update apidocs 206 | * [e793999](https://github.com/michaeljoseph/changes/commit/e793999) Refactor common_arguments 207 | * [c4bdab5](https://github.com/michaeljoseph/changes/commit/c4bdab5) Rename and refactor argument extract and strip 208 | * [7e3b12c](https://github.com/michaeljoseph/changes/commit/7e3b12c) Global arguments and reuse 209 | * [819b733](https://github.com/michaeljoseph/changes/commit/819b733) Refactor extract and increment 210 | 211 | ## [0.3.0](https://github.com/michaeljoseph/changes/compare/0.2.0...0.3.0) 212 | 213 | * [2d35668](https://github.com/michaeljoseph/changes/commit/2d35668) Fail on install, upload and pypi 214 | * [eaec843](https://github.com/michaeljoseph/changes/commit/eaec843) Cleanup 215 | * [3ba87ee](https://github.com/michaeljoseph/changes/commit/3ba87ee) Refactor `extract_version_arguments` 216 | * [ca909f4](https://github.com/michaeljoseph/changes/commit/ca909f4) Drop tag comparison on error and move newline split to `execute` 217 | * [6e7eee5](https://github.com/michaeljoseph/changes/commit/6e7eee5) Ignore idea 218 | * [31d4a14](https://github.com/michaeljoseph/changes/commit/31d4a14) readme improvements 219 | * [090f4c7](https://github.com/michaeljoseph/changes/commit/090f4c7) Update README with current workflow example 220 | 221 | ## [0.2.0](https://github.com/michaeljoseph/changes/compare/0.1.4...0.2.0) 222 | 223 | * [282afcd](https://github.com/michaeljoseph/changes/commit/282afcd) Assemble new workflow 224 | * [fc33494](https://github.com/michaeljoseph/changes/commit/fc33494) Factor out commit_version_change 225 | * [31ef222](https://github.com/michaeljoseph/changes/commit/31ef222) Fix changelog formatting bug mess 226 | * [4aab63e](https://github.com/michaeljoseph/changes/commit/4aab63e) Update cli in readme 227 | * [5344a4b](https://github.com/michaeljoseph/changes/commit/5344a4b) Linting 228 | * [64225a8](https://github.com/michaeljoseph/changes/commit/64225a8) Cleanup tmpdirs 229 | * [24123ed](https://github.com/michaeljoseph/changes/commit/24123ed) Add install from pypi command 230 | * [f2be719](https://github.com/michaeljoseph/changes/commit/f2be719) Add `--test-command` option to test installation 231 | * [09d534d](https://github.com/michaeljoseph/changes/commit/09d534d) Add install command and dependencies 232 | * [287da2f](https://github.com/michaeljoseph/changes/commit/287da2f) Test extract and alias `changes.cli` 233 | * [0debebb](https://github.com/michaeljoseph/changes/commit/0debebb) Update README to mention `nosetests` or `tox` requirement 234 | * [aaf07cc](https://github.com/michaeljoseph/changes/commit/aaf07cc) Adds tests for `extract_attribute` and `replace_attribute` 235 | * [a396425](https://github.com/michaeljoseph/changes/commit/a396425) Reorganise and lint 236 | * [38c9546](https://github.com/michaeljoseph/changes/commit/38c9546) Improve new_version handling and exempt test and upload from version prompting 237 | * [dd346c2](https://github.com/michaeljoseph/changes/commit/dd346c2) Move docopt string to docstring 238 | * [32307c1](https://github.com/michaeljoseph/changes/commit/32307c1) Add test command 239 | * [4326591](https://github.com/michaeljoseph/changes/commit/4326591) Add release command output 240 | 241 | ## [0.1.4](https://github.com/michaeljoseph/changes/compare/0.1.3...0.1.4) 242 | 243 | * [5ff1376](https://github.com/michaeljoseph/changes/commit/5ff1376) Fix changelog formatting test expectation 244 | * [c8cabfd](https://github.com/michaeljoseph/changes/commit/c8cabfd) Refactor changelog filename setting 245 | * [0aba00c](https://github.com/michaeljoseph/changes/commit/0aba00c) Replace `--commit-changelog` with `--skip-changelog` for a single automated release 246 | 247 | ## [0.1.3](https://github.com/michaeljoseph/changes/compare/0.1.2...0.1.3) 248 | 249 | * [b96ff3d](https://github.com/michaeljoseph/changes/commit/b96ff3d) Learn markdown 250 | 251 | ## [0.1.2](https://github.com/michaeljoseph/changes/compare/0.1.1...0.1.2) 252 | 253 | * [3c1e8c8](https://github.com/michaeljoseph/changes/commit/3c1e8c8) Actually commit the changelog 254 | * [9afc15f](https://github.com/michaeljoseph/changes/commit/9afc15f) Fix compare urls 255 | * [90f6ee1](https://github.com/michaeljoseph/changes/commit/90f6ee1) Missing slash 256 | * [378cb51](https://github.com/michaeljoseph/changes/commit/378cb51) Syntax 257 | * [7b8e35c](https://github.com/michaeljoseph/changes/commit/7b8e35c) This is not demands 258 | * [1ce4074](https://github.com/michaeljoseph/changes/commit/1ce4074) Passing `--commit-changelog` to `release` now commits your curated changelog 259 | * [46d56a9](https://github.com/michaeljoseph/changes/commit/46d56a9) Changelog update for 0.1.1 260 | * [b9cbce8](https://github.com/michaeljoseph/changes/commit/b9cbce8) Add example output to readme 261 | 262 | ## [0.1.1](https://github.com/yola/changes/compare/0.1.0...0.1.1) 263 | 264 | * [9df1964](https://github.com/michaeljoseph/changes/commit/9df1964) Don't overquote 265 | 266 | ## [0.1.0](https://github.com/yola/changes/compare/0.0.1...0.1.0) 267 | 268 | * [95e1f5d](https://github.com/michaeljoseph/changes/commit/95e1f5d) Document and implement the default workflow 269 | * [f410abf](https://github.com/michaeljoseph/changes/commit/f410abf) rtfd 270 | * [fcbac07](https://github.com/michaeljoseph/changes/commit/fcbac07) Reorder for a more natural workflow 271 | * [cad6418](https://github.com/michaeljoseph/changes/commit/cad6418) Unused imports 272 | * [34b0483](https://github.com/michaeljoseph/changes/commit/34b0483) Document `extract` 273 | * [f58c5d6](https://github.com/michaeljoseph/changes/commit/f58c5d6) :white_square: 274 | * [decf64d](https://github.com/michaeljoseph/changes/commit/decf64d) Add an option to commit the automatic changelog 275 | * [01d8519](https://github.com/michaeljoseph/changes/commit/01d8519) Correct path usage and indent 276 | * [73c7074](https://github.com/michaeljoseph/changes/commit/73c7074) path is in path 277 | * [c33f738](https://github.com/michaeljoseph/changes/commit/c33f738) Documentation update 278 | * [ccd65c1](https://github.com/michaeljoseph/changes/commit/ccd65c1) One command to release them all 279 | * [e97298d](https://github.com/michaeljoseph/changes/commit/e97298d) diff returns non-zero if there are differences, i just want the output 280 | * [20b6923](https://github.com/michaeljoseph/changes/commit/20b6923) Commit changelog changes 281 | * [c93c4a1](https://github.com/michaeljoseph/changes/commit/c93c4a1) Document `extract` and `increment` 282 | * [69eb0f8](https://github.com/michaeljoseph/changes/commit/69eb0f8) Markup commits in change log 283 | * [45a4a83](https://github.com/michaeljoseph/changes/commit/45a4a83) Cleanup 284 | * [e6427d7](https://github.com/michaeljoseph/changes/commit/e6427d7) Fix changelog test 285 | * [8ead025](https://github.com/michaeljoseph/changes/commit/8ead025) Use git commit log to write changelog entry 286 | * [2b34aaa](https://github.com/michaeljoseph/changes/commit/2b34aaa) Move test app creation to setUp / tearDown 287 | * [8ac08ee](https://github.com/michaeljoseph/changes/commit/8ac08ee) Add __url__ requirement to readme 288 | * [45b0977](https://github.com/michaeljoseph/changes/commit/45b0977) Separate arguments 289 | * [15e6c45](https://github.com/michaeljoseph/changes/commit/15e6c45) Missing -m 290 | * [a1a9845](https://github.com/michaeljoseph/changes/commit/a1a9845) Space 291 | 292 | ## 0.0.1 293 | 294 | * This version is first. 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Michael Joseph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV = venv/bin 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | .PHONY: clean venv help 6 | clean: ## Remove Python file artifacts and virtualenv 7 | @rm -rf venv 8 | 9 | venv: ## Creates the virtualenv and installs requirements 10 | python -m venv venv 11 | $(VENV)/pip install tox 12 | 13 | test:venv ## Run tests 14 | $(VENV)/tox -qe test 15 | 16 | lint:venv ## Lint source 17 | $(VENV)/tox -qe lint 18 | 19 | ci:test lint ## Continuous Integration Commands 20 | 21 | docs:test ## Generate documentation site 22 | $(VENV)/tox -qe docs 23 | @cp test-reports/test-report.html site/ 24 | @cp -R test-reports/coverage_html site/coverage 25 | 26 | package:docs ## Package project 27 | $(VENV)/tox -qe package 28 | 29 | serve:venv ## Serve documentation site 30 | @cp test-reports/test-report.html site/ 31 | @cp -R test-reports/coverage_html site/coverage 32 | $(VENV)/tox -qe docs -- serve 33 | 34 | help: 35 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-16s\033[0m %s\n", $$1, $$2}' 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ♻️ changes 2 | 3 | [![Github Actions](https://github.com/michaeljoseph/changes/actions/workflows/tests.yml/badge.svg)](https://github.com/michaeljoseph/changes/actions/workflows/tests.yml) 4 | [![Circle CI](https://circleci.com/gh/michaeljoseph/changes/tree/master.svg?style=svg&circle-token=773a0b46ffcd27626f0ff3bef788ffe96d47e473)](https://circleci.com/gh/michaeljoseph/changes/tree/master) 5 | [![pypi version](https://img.shields.io/pypi/v/changes.svg)](https://pypi.python.org/pypi/changes) 6 | [![# of downloads](https://img.shields.io/pypi/dw/changes.svg)](https://pypi.python.org/pypi/changes) 7 | [![codecov.io](https://codecov.io/github/michaeljoseph/changes/coverage.svg?branch=master)](https://codecov.io/github/michaeljoseph/changes?branch=master) 8 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/michaeljoseph/changes/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/michaeljoseph/changes/?branch=master) 9 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/michaeljoseph/changes?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 10 | 11 | 🎵 [Ch-ch-changes] 🎵 12 | 13 | ![changes](media/changes.png) 14 | 15 | ## ⚡️ Quickstart 16 | 17 | Install `changes` with `pipx`: 18 | 19 | ``` 20 | pipx install changes 21 | ``` 22 | 23 | ```bash 24 | $ changes --help 25 | Usage: changes [OPTIONS] COMMAND [ARGS]... 26 | 27 | Ch-ch-changes 28 | 29 | Options: 30 | -V, --version Show the version and exit. 31 | --verbose Enables verbose output. 32 | --dry-run Prints (instead of executing) the operations to be performed. 33 | -h, --help Show this message and exit. 34 | 35 | Commands: 36 | publish Publishes a release 37 | stage Stages a release 38 | status Shows current project release status. 39 | ``` 40 | 41 | ## 📺 Demo 42 | 43 |
44 | Expand 45 | changes demo 48 |
49 | 50 | ## 🛠 Development 51 | 52 | Use the `Makefile` targets to `test`, `lint` and generate the `docs`: 53 | 54 | ```bash 55 | $ make 56 | ci Continuous Integration Commands 57 | clean Remove Python file artifacts and virtualenv 58 | docs Generate documentation site 59 | lint Lint source 60 | serve Serve documentation site 61 | test Run tests 62 | venv Creates the virtualenv and installs requirements 63 | ``` 64 | 65 | [Ch-ch-changes]: http://www.youtube.com/watch?v=pl3vxEudif8 -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | https://gist.github.com/audreyr/5990987 -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - TOX_ENV: "py36" 4 | - TOX_ENV: "py37" 5 | 6 | install: 7 | - C:\Python36\python -m pip install tox 8 | 9 | build: false 10 | 11 | test_script: 12 | - C:\Python36\Scripts\tox -e %TOX_ENV% 13 | -------------------------------------------------------------------------------- /changes/__init__.py: -------------------------------------------------------------------------------- 1 | """Generates a github changelog, tags and uploads your python library""" 2 | from datetime import date 3 | from pathlib import Path 4 | 5 | from changes.config import Changes, Project 6 | from changes.models import Release, ReleaseType 7 | from changes.models.repository import GitHubRepository 8 | 9 | __version__ = '0.7.0' 10 | __url__ = 'https://github.com/michaeljoseph/changes' 11 | __author__ = 'Michael Joseph' 12 | __email__ = 'michaeljoseph@gmail.com' 13 | 14 | 15 | settings = None 16 | project_settings = None 17 | 18 | 19 | def initialise(): 20 | """ 21 | Detects, prompts and initialises the project. 22 | 23 | Stores project and tool configuration in the `changes` module. 24 | """ 25 | global settings, project_settings 26 | 27 | # Global changes settings 28 | settings = Changes.load() 29 | 30 | # Project specific settings 31 | project_settings = Project.load(GitHubRepository(auth_token=settings.auth_token)) 32 | 33 | 34 | def release_from_pull_requests(): 35 | global project_settings 36 | 37 | repository = project_settings.repository 38 | 39 | pull_requests = repository.pull_requests_since_latest_version 40 | 41 | labels = set( 42 | [ 43 | label_name 44 | for pull_request in pull_requests 45 | for label_name in pull_request.label_names 46 | ] 47 | ) 48 | 49 | descriptions = [ 50 | '\n'.join([pull_request.title, pull_request.description]) 51 | for pull_request in pull_requests 52 | ] 53 | 54 | bumpversion_part, release_type, proposed_version = determine_release( 55 | repository.latest_version, descriptions, labels 56 | ) 57 | 58 | releases_directory = Path(project_settings.releases_directory) 59 | if not releases_directory.exists(): 60 | releases_directory.mkdir(parents=True) 61 | 62 | release = Release( 63 | release_date=date.today().isoformat(), 64 | version=str(proposed_version), 65 | bumpversion_part=bumpversion_part, 66 | release_type=release_type, 67 | ) 68 | 69 | release_files = [release_file for release_file in releases_directory.glob('*.md')] 70 | if release_files: 71 | release_file = release_files[0] 72 | release.release_file_path = Path(project_settings.releases_directory).joinpath( 73 | release_file.name 74 | ) 75 | release.description = release_file.read_text() 76 | 77 | return release 78 | 79 | 80 | def determine_release(latest_version, descriptions, labels): 81 | if 'BREAKING CHANGE' in descriptions: 82 | return 'major', ReleaseType.BREAKING_CHANGE, latest_version.next_major() 83 | elif 'enhancement' in labels: 84 | return 'minor', ReleaseType.FEATURE, latest_version.next_minor() 85 | elif 'bug' in labels: 86 | return 'patch', ReleaseType.FIX, latest_version.next_patch() 87 | else: 88 | return None, ReleaseType.NO_CHANGE, latest_version 89 | -------------------------------------------------------------------------------- /changes/__main__.py: -------------------------------------------------------------------------------- 1 | import changes 2 | 3 | changes.main() 4 | -------------------------------------------------------------------------------- /changes/attributes.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import logging 3 | import tempfile 4 | from pathlib import Path 5 | 6 | from plumbum.cmd import diff 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | # TODO: leverage bumpversion 12 | def extract_attribute(module_name, attribute_name): 13 | """Extract metatdata property from a module""" 14 | with open('%s/__init__.py' % module_name) as input_file: 15 | for line in input_file: 16 | if line.startswith(attribute_name): 17 | return ast.literal_eval(line.split('=')[1].strip()) 18 | 19 | 20 | def replace_attribute(module_name, attribute_name, new_value, dry_run=True): 21 | """Update a metadata attribute""" 22 | init_file = '%s/__init__.py' % module_name 23 | _, tmp_file = tempfile.mkstemp() 24 | 25 | with open(init_file) as input_file: 26 | with open(tmp_file, 'w') as output_file: 27 | for line in input_file: 28 | if line.startswith(attribute_name): 29 | line = "%s = '%s'\n" % (attribute_name, new_value) 30 | 31 | output_file.write(line) 32 | 33 | if not dry_run: 34 | Path(tmp_file).copy(init_file) 35 | else: 36 | log.info(diff(tmp_file, init_file, retcode=None)) 37 | 38 | 39 | def has_attribute(module_name, attribute_name): 40 | """Is this attribute present?""" 41 | init_file = '%s/__init__.py' % module_name 42 | return any( 43 | [attribute_name in init_line for init_line in open(init_file).readlines()] 44 | ) 45 | -------------------------------------------------------------------------------- /changes/changelog.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | import re 4 | 5 | from plumbum.cmd import git 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def write_new_changelog(repo_url, filename, content_lines, dry_run=True): 11 | heading_and_newline = '# [Changelog](%s/releases)\n' % repo_url 12 | 13 | with io.open(filename, 'r+') as f: 14 | existing = f.readlines() 15 | 16 | output = existing[2:] 17 | output.insert(0, '\n') 18 | 19 | for index, line in enumerate(content_lines): 20 | output.insert(0, content_lines[len(content_lines) - index - 1]) 21 | 22 | output.insert(0, heading_and_newline) 23 | 24 | output = ''.join(output) 25 | 26 | if not dry_run: 27 | with io.open(filename, 'w+') as f: 28 | f.write(output) 29 | else: 30 | log.info('New changelog:\n%s', ''.join(content_lines)) 31 | 32 | 33 | def replace_sha_with_commit_link(repo_url, git_log_content): 34 | git_log_content = git_log_content.split('\n') 35 | for index, line in enumerate(git_log_content): 36 | # http://stackoverflow.com/a/468378/5549 37 | sha1_re = re.match(r'^[0-9a-f]{5,40}\b', line) 38 | if sha1_re: 39 | sha1 = sha1_re.group() 40 | 41 | new_line = line.replace(sha1, '[%s](%s/commit/%s)' % (sha1, repo_url, sha1)) 42 | log.debug('old line: %s\nnew line: %s', line, new_line) 43 | git_log_content[index] = new_line 44 | 45 | return git_log_content 46 | 47 | 48 | def generate_changelog(context): 49 | """Generates an automatic changelog from your commit messages.""" 50 | 51 | changelog_content = [ 52 | '\n## [%s](%s/compare/%s...%s)\n\n' 53 | % ( 54 | context.new_version, 55 | context.repo_url, 56 | context.current_version, 57 | context.new_version, 58 | ) 59 | ] 60 | 61 | git_log_content = None 62 | git_log = 'log --oneline --no-merges --no-color'.split(' ') 63 | try: 64 | git_log_tag = git_log + ['%s..master' % context.current_version] 65 | git_log_content = git(git_log_tag) 66 | log.debug('content: %s' % git_log_content) 67 | except Exception: 68 | log.warn('Error diffing previous version, initial release') 69 | git_log_content = git(git_log) 70 | 71 | git_log_content = replace_sha_with_commit_link(context.repo_url, git_log_content) 72 | # turn change log entries into markdown bullet points 73 | if git_log_content: 74 | [ 75 | changelog_content.append('* %s\n' % line) if line else line 76 | for line in git_log_content[:-1] 77 | ] 78 | 79 | write_new_changelog( 80 | context.repo_url, 'CHANGELOG.md', changelog_content, dry_run=context.dry_run 81 | ) 82 | log.info('Added content to CHANGELOG.md') 83 | context.changelog_content = changelog_content 84 | -------------------------------------------------------------------------------- /changes/cli.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | 4 | import click 5 | import requests_cache 6 | 7 | import changes 8 | from changes.commands import ( 9 | publish as publish_command, 10 | stage as stage_command, 11 | status as status_command, 12 | ) 13 | 14 | from . import __version__ 15 | 16 | VERSION = 'changes {}'.format(__version__) 17 | 18 | 19 | @contextlib.contextmanager 20 | def work_in(dirname=None): 21 | """ 22 | Context manager version of os.chdir. When exited, returns to the working 23 | directory prior to entering. 24 | """ 25 | curdir = os.getcwd() 26 | try: 27 | if dirname is not None: 28 | os.chdir(dirname) 29 | 30 | requests_cache.configure(expire_after=60 * 10 * 10) 31 | changes.initialise() 32 | 33 | yield 34 | 35 | finally: 36 | os.chdir(curdir) 37 | 38 | 39 | def print_version(context, param, value): 40 | if not value or context.resilient_parsing: 41 | return 42 | click.echo(VERSION) 43 | context.exit() 44 | 45 | 46 | @click.option( 47 | '--dry-run', 48 | help='Prints (instead of executing) the operations to be performed.', 49 | is_flag=True, 50 | default=False, 51 | ) 52 | @click.option('--verbose', help='Enables verbose output.', is_flag=True, default=False) 53 | @click.version_option(__version__, '-V', '--version', message=VERSION) 54 | @click.group(context_settings=dict(help_option_names=[u'-h', u'--help'])) 55 | def main(dry_run, verbose): 56 | """Ch-ch-changes""" 57 | 58 | 59 | @click.command() 60 | @click.argument('repo_directory', required=False) 61 | def status(repo_directory): 62 | """ 63 | Shows current project release status. 64 | """ 65 | repo_directory = repo_directory if repo_directory else '.' 66 | 67 | with work_in(repo_directory): 68 | status_command.status() 69 | 70 | 71 | main.add_command(status) 72 | 73 | 74 | @click.command() 75 | @click.option('--draft', help='Dry-run mode.', is_flag=True, default=False) 76 | @click.option( 77 | '--discard', 78 | help='Discards the changes made to release files', 79 | is_flag=True, 80 | default=False, 81 | ) 82 | @click.argument('repo_directory', default='.', required=False) 83 | @click.argument('release_name', required=False) 84 | @click.argument('release_description', required=False) 85 | def stage(draft, discard, repo_directory, release_name, release_description): 86 | """ 87 | Stages a release 88 | """ 89 | with work_in(repo_directory): 90 | if discard: 91 | stage_command.discard(release_name, release_description) 92 | else: 93 | stage_command.stage(draft, release_name, release_description) 94 | 95 | 96 | main.add_command(stage) 97 | 98 | 99 | @click.command() 100 | @click.argument('repo_directory', default='.', required=False) 101 | def publish(repo_directory): 102 | """ 103 | Publishes a release 104 | """ 105 | with work_in(repo_directory): 106 | publish_command.publish() 107 | 108 | 109 | main.add_command(publish) 110 | -------------------------------------------------------------------------------- /changes/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | STYLES = { 4 | 'debug': {'fg': 'blue'}, 5 | 'info': {'fg': 'green', 'bold': True}, 6 | 'highlight': {'fg': 'cyan', 'bold': True}, 7 | 'note': {'fg': 'blue', 'bold': True}, 8 | 'error': {'fg': 'red', 'bold': True}, 9 | } 10 | 11 | 12 | def echo(message, style): 13 | click.secho(str(message), **STYLES[style]) 14 | 15 | 16 | def debug(message): 17 | echo('{}...'.format(message), 'debug') 18 | 19 | 20 | def info(message): 21 | echo('{}...'.format(message), 'info') 22 | 23 | 24 | def note(message): 25 | echo(message, 'note') 26 | 27 | 28 | def note_style(message): 29 | return click.style(message, **STYLES['note']) 30 | 31 | 32 | def highlight(message): 33 | return click.style(message, **STYLES['highlight']) 34 | 35 | 36 | def error(message): 37 | echo(message, 'error') 38 | -------------------------------------------------------------------------------- /changes/commands/publish.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import click 4 | 5 | import changes 6 | from changes.commands import info 7 | from changes.models import BumpVersion 8 | 9 | 10 | def publish(): 11 | repository = changes.project_settings.repository 12 | 13 | release = changes.release_from_pull_requests() 14 | 15 | if release.version == str(repository.latest_version): 16 | info('No staged release to publish') 17 | return 18 | 19 | info('Publishing release {}'.format(release.version)) 20 | 21 | files_to_add = BumpVersion.read_from_file( 22 | Path('.bumpversion.cfg') 23 | ).version_files_to_replace 24 | files_to_add += ['.bumpversion.cfg', str(release.release_file_path)] 25 | 26 | info('Running: git add {}'.format(' '.join(files_to_add))) 27 | repository.add(files_to_add) 28 | 29 | commit_message = release.release_file_path.read_text(encoding='utf-8') 30 | info('Running: git commit --message="{}"'.format(commit_message)) 31 | repository.commit(commit_message) 32 | 33 | info('Running: git tag {}'.format(release.version)) 34 | repository.tag(release.version) 35 | 36 | if click.confirm('Happy to publish release {}'.format(release.version)): 37 | info('Running: git push --tags') 38 | repository.push() 39 | 40 | info('Creating GitHub Release') 41 | repository.create_release(release) 42 | 43 | info('Published release {}'.format(release.version)) 44 | -------------------------------------------------------------------------------- /changes/commands/stage.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from pathlib import Path 3 | 4 | import bumpversion 5 | import click 6 | import pkg_resources 7 | from jinja2 import Template 8 | 9 | import changes 10 | from changes.models import BumpVersion, Release 11 | 12 | from . import STYLES, debug, error, info 13 | 14 | 15 | def discard(release_name='', release_description=''): 16 | repository = changes.project_settings.repository 17 | 18 | release = changes.release_from_pull_requests() 19 | if release.version == str(repository.latest_version): 20 | info('No staged release to discard') 21 | return 22 | 23 | info('Discarding currently staged release {}'.format(release.version)) 24 | 25 | bumpversion = BumpVersion.read_from_file(Path('.bumpversion.cfg')) 26 | git_discard_files = bumpversion.version_files_to_replace + [ 27 | # 'CHANGELOG.md', 28 | '.bumpversion.cfg' 29 | ] 30 | 31 | info('Running: git {}'.format(' '.join(['checkout', '--'] + git_discard_files))) 32 | repository.discard(git_discard_files) 33 | 34 | if release.release_file_path.exists(): 35 | info('Running: rm {}'.format(release.release_file_path)) 36 | release.release_file_path.unlink() 37 | 38 | 39 | def stage(draft, release_name='', release_description=''): 40 | repository = changes.project_settings.repository 41 | 42 | release = changes.release_from_pull_requests() 43 | release.name = release_name 44 | release.description = release_description 45 | 46 | if not repository.pull_requests_since_latest_version: 47 | error("There aren't any changes to release since {}".format(release.version)) 48 | return 49 | 50 | info( 51 | 'Staging [{}] release for version {}'.format( 52 | release.release_type, release.version 53 | ) 54 | ) 55 | 56 | # Bumping versions 57 | if BumpVersion.read_from_file(Path('.bumpversion.cfg')).current_version == str( 58 | release.version 59 | ): 60 | info('Version already bumped to {}'.format(release.version)) 61 | else: 62 | bumpversion_arguments = ( 63 | BumpVersion.DRAFT_OPTIONS if draft else BumpVersion.STAGE_OPTIONS 64 | ) + [release.bumpversion_part] 65 | 66 | info('Running: bumpversion {}'.format(' '.join(bumpversion_arguments))) 67 | bumpversion.main(bumpversion_arguments) 68 | 69 | # Release notes generation 70 | info('Generating Release') 71 | release.notes = Release.generate_notes( 72 | changes.project_settings.labels, repository.pull_requests_since_latest_version 73 | ) 74 | 75 | # TODO: if project_settings.release_notes_template is None 76 | release_notes_template = pkg_resources.resource_string( 77 | changes.__name__, 'templates/release_notes_template.md' 78 | ).decode('utf8') 79 | 80 | release_notes = Template(release_notes_template).render(release=release) 81 | 82 | releases_directory = Path(changes.project_settings.releases_directory) 83 | if not releases_directory.exists(): 84 | releases_directory.mkdir(parents=True) 85 | 86 | release_notes_path = releases_directory.joinpath( 87 | '{}.md'.format(release.release_note_filename) 88 | ) 89 | 90 | if draft: 91 | info('Would have created {}:'.format(release_notes_path)) 92 | debug(release_notes) 93 | else: 94 | info('Writing release notes to {}'.format(release_notes_path)) 95 | if release_notes_path.exists(): 96 | release_notes_content = release_notes_path.read_text(encoding='utf-8') 97 | if release_notes_content != release_notes: 98 | info( 99 | '\n'.join( 100 | difflib.unified_diff( 101 | release_notes_content.splitlines(), 102 | release_notes.splitlines(), 103 | fromfile=str(release_notes_path), 104 | tofile=str(release_notes_path), 105 | ) 106 | ) 107 | ) 108 | if click.confirm( 109 | click.style( 110 | '{} has modified content, overwrite?'.format( 111 | release_notes_path 112 | ), 113 | **STYLES['error'] 114 | ) 115 | ): 116 | release_notes_path.write_text(release_notes, encoding='utf-8') 117 | else: 118 | release_notes_path.write_text(release_notes, encoding='utf-8') 119 | -------------------------------------------------------------------------------- /changes/commands/status.py: -------------------------------------------------------------------------------- 1 | import changes 2 | 3 | from . import highlight, info, note 4 | 5 | 6 | def status(): 7 | repository = changes.project_settings.repository 8 | 9 | release = changes.release_from_pull_requests() 10 | 11 | info('Status [{}/{}]'.format(repository.owner, repository.repo)) 12 | 13 | info('Repository: ' + highlight('{}/{}'.format(repository.owner, repository.repo))) 14 | 15 | info('Latest Version') 16 | note(repository.latest_version) 17 | 18 | info('Changes') 19 | unreleased_changes = repository.pull_requests_since_latest_version 20 | note( 21 | '{} changes found since {}'.format( 22 | len(unreleased_changes), repository.latest_version 23 | ) 24 | ) 25 | 26 | for pull_request in unreleased_changes: 27 | note( 28 | '#{} {} by @{}{}'.format( 29 | pull_request.number, 30 | pull_request.title, 31 | pull_request.author, 32 | ' [{}]'.format(','.join(pull_request.label_names)) 33 | if pull_request.label_names 34 | else '', 35 | ) 36 | ) 37 | 38 | if unreleased_changes: 39 | info( 40 | 'Computed release type {} from changes issue tags'.format( 41 | release.release_type 42 | ) 43 | ) 44 | info( 45 | 'Proposed version bump {} => {}'.format( 46 | repository.latest_version, release.version 47 | ) 48 | ) 49 | -------------------------------------------------------------------------------- /changes/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | IS_WINDOWS = 'win32' in str(sys.platform).lower() 4 | -------------------------------------------------------------------------------- /changes/config.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from os.path import curdir, exists, expanduser, expandvars, join 4 | from pathlib import Path 5 | 6 | import attr 7 | import click 8 | import inflection 9 | import toml 10 | 11 | from changes import compat, prompt 12 | from changes.models import BumpVersion 13 | 14 | from .commands import debug, info, note 15 | 16 | AUTH_TOKEN_ENVVAR = 'GITHUB_AUTH_TOKEN' 17 | 18 | # via https://github.com/jakubroztocil/httpie/blob/6bdfc7a/httpie/config.py#L9 19 | DEFAULT_CONFIG_FILE = str( 20 | os.environ.get( 21 | 'CHANGES_CONFIG_FILE', 22 | expanduser('~/.changes') 23 | if not compat.IS_WINDOWS 24 | else expandvars(r'%APPDATA%\\.changes'), 25 | ) 26 | ) 27 | PROJECT_CONFIG_FILE = '.changes.toml' 28 | DEFAULT_RELEASES_DIRECTORY = 'docs/releases' 29 | 30 | 31 | @attr.s 32 | class Changes(object): 33 | auth_token = attr.ib() 34 | 35 | @classmethod 36 | def load(cls): 37 | tool_config_path = Path( 38 | str( 39 | os.environ.get( 40 | 'CHANGES_CONFIG_FILE', 41 | expanduser('~/.changes') 42 | if not compat.IS_WINDOWS 43 | else expandvars(r'%APPDATA%\\.changes'), 44 | ) 45 | ) 46 | ) 47 | 48 | tool_settings = None 49 | if tool_config_path.exists(): 50 | tool_settings = Changes(**(toml.load(tool_config_path.open())['changes'])) 51 | 52 | # envvar takes precedence over config file settings 53 | auth_token = os.environ.get(AUTH_TOKEN_ENVVAR) 54 | if auth_token: 55 | info('Found Github Auth Token in the environment') 56 | tool_settings = Changes(auth_token=auth_token) 57 | elif not (tool_settings and tool_settings.auth_token): 58 | while not auth_token: 59 | info('No auth token found, asking for it') 60 | # to interact with the Git*H*ub API 61 | note('You need a Github Auth Token for changes to create a release.') 62 | click.pause( 63 | 'Press [enter] to launch the GitHub "New personal access ' 64 | 'token" page, to create a token for changes.' 65 | ) 66 | click.launch('https://github.com/settings/tokens/new') 67 | auth_token = click.prompt('Enter your changes token') 68 | 69 | if not tool_settings: 70 | tool_settings = Changes(auth_token=auth_token) 71 | 72 | tool_config_path.write_text( 73 | toml.dumps({'changes': attr.asdict(tool_settings)}) 74 | ) 75 | 76 | return tool_settings 77 | 78 | 79 | @attr.s 80 | class Project(object): 81 | releases_directory = attr.ib() 82 | repository = attr.ib(default=None) 83 | bumpversion = attr.ib(default=None) 84 | labels = attr.ib(default=attr.Factory(dict)) 85 | 86 | @classmethod 87 | def load(cls, repository): 88 | changes_project_config_path = Path(PROJECT_CONFIG_FILE) 89 | project_settings = None 90 | 91 | if changes_project_config_path.exists(): 92 | # releases_directory, labels 93 | project_settings = Project( 94 | **(toml.load(changes_project_config_path.open())['changes']) 95 | ) 96 | 97 | if not project_settings: 98 | releases_directory = Path( 99 | click.prompt( 100 | 'Enter the directory to store your releases notes', 101 | DEFAULT_RELEASES_DIRECTORY, 102 | type=click.Path(exists=True, dir_okay=True), 103 | ) 104 | ) 105 | 106 | if not releases_directory.exists(): 107 | debug( 108 | 'Releases directory {} not found, creating it.'.format( 109 | releases_directory 110 | ) 111 | ) 112 | releases_directory.mkdir(parents=True) 113 | 114 | project_settings = Project( 115 | releases_directory=str(releases_directory), 116 | labels=configure_labels(repository.labels), 117 | ) 118 | # write config file 119 | changes_project_config_path.write_text( 120 | toml.dumps({'changes': attr.asdict(project_settings)}) 121 | ) 122 | 123 | project_settings.repository = repository 124 | project_settings.bumpversion = BumpVersion.load(repository.latest_version) 125 | 126 | return project_settings 127 | 128 | 129 | def configure_labels(github_labels): 130 | labels_keyed_by_name = {} 131 | for label in github_labels: 132 | labels_keyed_by_name[label['name']] = label 133 | 134 | # TODO: streamlined support for github defaults: enhancement, bug 135 | changelog_worthy_labels = prompt.choose_labels( 136 | [properties['name'] for _, properties in labels_keyed_by_name.items()] 137 | ) 138 | 139 | # TODO: apply description transform in labels_prompt function 140 | described_labels = {} 141 | # auto-generate label descriptions 142 | for label_name in changelog_worthy_labels: 143 | label_properties = labels_keyed_by_name[label_name] 144 | # Auto-generate description as pluralised titlecase label name 145 | label_properties['description'] = inflection.pluralize( 146 | inflection.titleize(label_name) 147 | ) 148 | 149 | described_labels[label_name] = label_properties 150 | 151 | return described_labels 152 | 153 | 154 | # TODO: borg legacy 155 | DEFAULTS = { 156 | 'changelog': 'CHANGELOG.md', 157 | 'readme': 'README.md', 158 | 'github_auth_token': None, 159 | } 160 | 161 | 162 | class Config: 163 | """Deprecated""" 164 | 165 | test_command = None 166 | pypi = None 167 | skip_changelog = None 168 | changelog_content = None 169 | repo = None 170 | 171 | def __init__( 172 | self, 173 | module_name, 174 | dry_run, 175 | debug, 176 | no_input, 177 | requirements, 178 | new_version, 179 | current_version, 180 | repo_url, 181 | version_prefix, 182 | ): 183 | self.module_name = module_name 184 | # module_name => project_name => curdir 185 | self.dry_run = dry_run 186 | self.debug = debug 187 | self.no_input = no_input 188 | self.requirements = requirements 189 | self.new_version = ( 190 | version_prefix + new_version if version_prefix else new_version 191 | ) 192 | self.current_version = current_version 193 | 194 | 195 | def project_config(): 196 | """Deprecated""" 197 | project_name = curdir 198 | 199 | config_path = Path(join(project_name, PROJECT_CONFIG_FILE)) 200 | 201 | if not exists(config_path): 202 | store_settings(DEFAULTS.copy()) 203 | return DEFAULTS 204 | 205 | return toml.load(io.open(config_path)) or {} 206 | 207 | 208 | def store_settings(settings): 209 | pass 210 | -------------------------------------------------------------------------------- /changes/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProbeException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /changes/flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | 5 | from changes.changelog import generate_changelog 6 | from changes.config import project_config, store_settings 7 | from changes.packaging import ( 8 | build_distributions, 9 | install_from_pypi, 10 | install_package, 11 | upload_package, 12 | ) 13 | from changes.vcs import ( 14 | commit_version_change, 15 | create_github_release, 16 | tag_and_push, 17 | upload_release_distributions, 18 | ) 19 | from changes.verification import run_tests 20 | from changes.version import increment_version 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | def publish(context): 26 | """Publishes the project""" 27 | commit_version_change(context) 28 | 29 | if context.github: 30 | # github token 31 | project_settings = project_config(context.module_name) 32 | if not project_settings['gh_token']: 33 | click.echo('You need a GitHub token for changes to create a release.') 34 | click.pause( 35 | 'Press [enter] to launch the GitHub "New personal access ' 36 | 'token" page, to create a token for changes.' 37 | ) 38 | click.launch('https://github.com/settings/tokens/new') 39 | project_settings['gh_token'] = click.prompt('Enter your changes token') 40 | 41 | store_settings(context.module_name, project_settings) 42 | description = click.prompt('Describe this release') 43 | 44 | upload_url = create_github_release( 45 | context, project_settings['gh_token'], description 46 | ) 47 | 48 | upload_release_distributions( 49 | context, 50 | project_settings['gh_token'], 51 | build_distributions(context), 52 | upload_url, 53 | ) 54 | 55 | click.pause('Press [enter] to review and update your new release') 56 | click.launch( 57 | '{0}/releases/tag/{1}'.format(context.repo_url, context.new_version) 58 | ) 59 | else: 60 | tag_and_push(context) 61 | 62 | 63 | def perform_release(context): 64 | """Executes the release process.""" 65 | try: 66 | run_tests() 67 | 68 | if not context.skip_changelog: 69 | generate_changelog(context) 70 | 71 | increment_version(context) 72 | 73 | build_distributions(context) 74 | 75 | install_package(context) 76 | 77 | upload_package(context) 78 | 79 | install_from_pypi(context) 80 | 81 | publish(context) 82 | except Exception: 83 | log.exception('Error releasing') 84 | -------------------------------------------------------------------------------- /changes/models/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import textwrap 3 | from configparser import RawConfigParser 4 | from enum import Enum 5 | from pathlib import Path 6 | 7 | import attr 8 | import click 9 | 10 | 11 | class ReleaseType(str, Enum): 12 | NO_CHANGE = 'no-changes' 13 | BREAKING_CHANGE = 'breaking' 14 | FEATURE = 'feature' 15 | FIX = 'fix' 16 | 17 | 18 | @attr.s 19 | class Release(object): 20 | release_date = attr.ib() 21 | version = attr.ib() 22 | description = attr.ib(default=attr.Factory(str)) 23 | name = attr.ib(default=attr.Factory(str)) 24 | notes = attr.ib(default=attr.Factory(dict)) 25 | release_file_path = attr.ib(default='') 26 | 27 | bumpversion_part = attr.ib(default=None) 28 | release_type = attr.ib(default=None) 29 | 30 | @property 31 | def title(self): 32 | return '{version} ({release_date})'.format( 33 | version=self.version, release_date=self.release_date 34 | ) + ((' ' + self.name) if self.name else '') 35 | 36 | @property 37 | def release_note_filename(self): 38 | return '{version}-{release_date}'.format( 39 | version=self.version, release_date=self.release_date 40 | ) + (('-' + self.name) if self.name else '') 41 | 42 | @classmethod 43 | def generate_notes(cls, project_labels, pull_requests_since_latest_version): 44 | for label, properties in project_labels.items(): 45 | pull_requests_with_label = [ 46 | pull_request 47 | for pull_request in pull_requests_since_latest_version 48 | if label in pull_request.label_names 49 | ] 50 | 51 | project_labels[label]['pull_requests'] = pull_requests_with_label 52 | 53 | return project_labels 54 | 55 | 56 | @attr.s 57 | class BumpVersion(object): 58 | DRAFT_OPTIONS = [ 59 | '--dry-run', 60 | '--verbose', 61 | '--no-commit', 62 | '--no-tag', 63 | '--allow-dirty', 64 | ] 65 | STAGE_OPTIONS = ['--verbose', '--allow-dirty', '--no-commit', '--no-tag'] 66 | 67 | current_version = attr.ib() 68 | version_files_to_replace = attr.ib(default=attr.Factory(list)) 69 | 70 | @classmethod 71 | def load(cls, latest_version): 72 | # TODO: look in other supported bumpversion config locations 73 | bumpversion = None 74 | bumpversion_config_path = Path('.bumpversion.cfg') 75 | if not bumpversion_config_path.exists(): 76 | user_supplied_versioned_file_paths = [] 77 | 78 | version_file_path_answer = None 79 | input_terminator = '.' 80 | while not version_file_path_answer == input_terminator: 81 | version_file_path_answer = click.prompt( 82 | 'Enter a path to a file that contains a version number ' 83 | "(enter a path of '.' when you're done selecting files)", 84 | type=click.Path( 85 | exists=True, dir_okay=True, file_okay=True, readable=True 86 | ), 87 | ) 88 | 89 | if version_file_path_answer != input_terminator: 90 | user_supplied_versioned_file_paths.append(version_file_path_answer) 91 | 92 | bumpversion = cls( 93 | current_version=latest_version, 94 | version_files_to_replace=user_supplied_versioned_file_paths, 95 | ) 96 | bumpversion.write_to_file(bumpversion_config_path) 97 | 98 | return bumpversion 99 | 100 | @classmethod 101 | def read_from_file(cls, config_path: Path): 102 | config = RawConfigParser('') 103 | config.readfp(config_path.open('rt', encoding='utf-8')) 104 | 105 | current_version = config.get("bumpversion", 'current_version') 106 | 107 | filenames = [] 108 | for section_name in config.sections(): 109 | 110 | section_name_match = re.compile("^bumpversion:(file|part):(.+)").match( 111 | section_name 112 | ) 113 | 114 | if not section_name_match: 115 | continue 116 | 117 | section_prefix, section_value = section_name_match.groups() 118 | 119 | if section_prefix == "file": 120 | filenames.append(section_value) 121 | 122 | return cls(current_version=current_version, version_files_to_replace=filenames) 123 | 124 | def write_to_file(self, config_path: Path): 125 | bumpversion_cfg = textwrap.dedent( 126 | """\ 127 | [bumpversion] 128 | current_version = {current_version} 129 | 130 | """ 131 | ).format(**attr.asdict(self)) 132 | 133 | bumpversion_files = '\n\n'.join( 134 | [ 135 | '[bumpversion:file:{}]'.format(file_name) 136 | for file_name in self.version_files_to_replace 137 | ] 138 | ) 139 | 140 | config_path.write_text(bumpversion_cfg + bumpversion_files) 141 | -------------------------------------------------------------------------------- /changes/models/repository.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shlex 3 | 4 | import attr 5 | import giturlparse 6 | import semantic_version 7 | from plumbum.cmd import git as git_command 8 | 9 | from changes import services 10 | from changes.compat import IS_WINDOWS 11 | 12 | GITHUB_MERGED_PULL_REQUEST = re.compile(r'^([0-9a-f]{5,40}) Merge pull request #(\w+)') 13 | 14 | 15 | def git(command): 16 | command = shlex.split(command, posix=not IS_WINDOWS) 17 | return git_command[command]() 18 | 19 | 20 | def git_lines(command): 21 | return git(command).splitlines() 22 | 23 | 24 | @attr.s 25 | class GitRepository(object): 26 | VERSION_ZERO = semantic_version.Version('0.0.0') 27 | # TODO: handle multiple remotes (for non-owner maintainer workflows) 28 | REMOTE_NAME = 'origin' 29 | 30 | auth_token = attr.ib(default=None) 31 | 32 | @property 33 | def remote_url(self): 34 | return git('config --get remote.{}.url'.format(self.REMOTE_NAME)) 35 | 36 | @property 37 | def parsed_repo(self): 38 | return giturlparse.parse(self.remote_url) 39 | 40 | @property 41 | def repo(self): 42 | return self.parsed_repo.repo 43 | 44 | @property 45 | def owner(self): 46 | return self.parsed_repo.owner 47 | 48 | @property 49 | def platform(self): 50 | return self.parsed_repo.platform 51 | 52 | @property 53 | def is_github(self): 54 | return self.parsed_repo.github 55 | 56 | @property 57 | def is_bitbucket(self): 58 | return self.parsed_repo.bitbucket 59 | 60 | @property 61 | def commit_history(self): 62 | return [ 63 | commit_message 64 | for commit_message in git_lines('log --oneline --no-color') 65 | if commit_message 66 | ] 67 | 68 | @property 69 | def first_commit_sha(self): 70 | return git('rev-list --max-parents=0 HEAD') 71 | 72 | @property 73 | def tags(self): 74 | return git_lines('tag --list') 75 | 76 | @property 77 | def versions(self): 78 | versions = [] 79 | for tag in self.tags: 80 | try: 81 | versions.append(semantic_version.Version(tag)) 82 | except ValueError: 83 | pass 84 | return versions 85 | 86 | @property 87 | def latest_version(self) -> semantic_version.Version: 88 | return max(self.versions) if self.versions else self.VERSION_ZERO 89 | 90 | def merges_since(self, version=None): 91 | if version == semantic_version.Version('0.0.0'): 92 | version = self.first_commit_sha 93 | 94 | revision_range = ' {}..HEAD'.format(version) if version else '' 95 | 96 | merge_commits = git( 97 | 'log --oneline --merges --no-color{}'.format(revision_range) 98 | ).split('\n') 99 | return merge_commits 100 | 101 | @property 102 | def merges_since_latest_version(self): 103 | return self.merges_since(self.latest_version) 104 | 105 | @property 106 | def files_modified_in_last_commit(self): 107 | return git('diff --name -only --diff -filter=d') 108 | 109 | @property 110 | def dirty_files(self): 111 | return [ 112 | modified_path 113 | for modified_path in git('-c color.status=false status --short --branch') 114 | if modified_path.startswith(' M') 115 | ] 116 | 117 | @staticmethod 118 | def add(files_to_add): 119 | return git('add {}'.format(' '.join(files_to_add))) 120 | 121 | @staticmethod 122 | def commit(message): 123 | # FIXME: message is one token 124 | return git_command['commit', '--message="{}"'.format(message)]() 125 | 126 | @staticmethod 127 | def discard(file_paths): 128 | return git('checkout -- {}'.format(' '.join(file_paths))) 129 | 130 | @staticmethod 131 | def tag(version): 132 | # TODO: signed tags 133 | return git( 134 | 'tag --annotate {version} --message="{version}"'.format(version=version) 135 | ) 136 | 137 | @staticmethod 138 | def push(): 139 | return git('push --tags') 140 | 141 | 142 | @attr.s 143 | class GitHubRepository(GitRepository): 144 | api = attr.ib(default=None) 145 | 146 | def __attrs_post_init__(self): 147 | self.api = services.GitHub(self) 148 | 149 | @property 150 | def labels(self): 151 | return self.api.labels() 152 | 153 | @property 154 | def pull_requests_since_latest_version(self): 155 | return [ 156 | PullRequest.from_github(self.api.pull_request(pull_request_number)) 157 | for pull_request_number in self.pull_request_numbers_since_latest_version 158 | ] 159 | 160 | @property 161 | def pull_request_numbers_since_latest_version(self): 162 | pull_request_numbers = [] 163 | 164 | for commit_msg in self.merges_since(self.latest_version): 165 | 166 | matches = GITHUB_MERGED_PULL_REQUEST.findall(commit_msg) 167 | 168 | if matches: 169 | _, pull_request_number = matches[0] 170 | pull_request_numbers.append(pull_request_number) 171 | 172 | return pull_request_numbers 173 | 174 | def create_release(self, release): 175 | return self.api.create_release(release) 176 | 177 | 178 | @attr.s 179 | class PullRequest(object): 180 | number = attr.ib() 181 | title = attr.ib() 182 | description = attr.ib() 183 | author = attr.ib() 184 | body = attr.ib() 185 | user = attr.ib() 186 | labels = attr.ib(default=attr.Factory(list)) 187 | 188 | @property 189 | def description(self): 190 | return self.body 191 | 192 | @property 193 | def author(self): 194 | return self.user['login'] 195 | 196 | @property 197 | def label_names(self): 198 | return [label['name'] for label in self.labels] 199 | 200 | @classmethod 201 | def from_github(cls, api_response): 202 | return cls(**{k.name: api_response[k.name] for k in attr.fields(cls)}) 203 | 204 | @classmethod 205 | def from_number(cls, number): 206 | pass 207 | -------------------------------------------------------------------------------- /changes/packaging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from shutil import rmtree 4 | 5 | from changes import shell, util, venv, verification 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def build_distributions(context): 11 | """Builds package distributions""" 12 | rmtree('dist', ignore_errors=True) 13 | 14 | build_package_command = 'python setup.py clean sdist bdist_wheel' 15 | result = shell.dry_run(build_package_command, context.dry_run) 16 | packages = Path('dist').files() if not context.dry_run else "nothing" 17 | 18 | if not result: 19 | raise Exception('Error building packages: %s' % result) 20 | else: 21 | log.info('Built %s' % ', '.join(packages)) 22 | return packages 23 | 24 | 25 | # tox 26 | def install_package(context): 27 | """Attempts to install the sdist and wheel.""" 28 | 29 | if not context.dry_run and build_distributions(context): 30 | with util.mktmpdir() as tmp_dir: 31 | venv.create_venv(tmp_dir=tmp_dir) 32 | for distribution in Path('dist').files(): 33 | try: 34 | venv.install(distribution, tmp_dir) 35 | log.info('Successfully installed %s', distribution) 36 | if context.test_command and verification.run_test_command(context): 37 | log.info( 38 | 'Successfully ran test command: %s', context.test_command 39 | ) 40 | except Exception as e: 41 | raise Exception( 42 | 'Error installing distribution %s' % distribution, e 43 | ) 44 | else: 45 | log.info('Dry run, skipping installation') 46 | 47 | 48 | # twine 49 | def upload_package(context): 50 | """Uploads your project packages to pypi with twine.""" 51 | 52 | if not context.dry_run and build_distributions(context): 53 | upload_args = 'twine upload ' 54 | upload_args += ' '.join(Path('dist').files()) 55 | if context.pypi: 56 | upload_args += ' -r %s' % context.pypi 57 | 58 | upload_result = shell.dry_run(upload_args, context.dry_run) 59 | if not context.dry_run and not upload_result: 60 | raise Exception('Error uploading: %s' % upload_result) 61 | else: 62 | log.info( 63 | 'Successfully uploaded %s:%s', context.module_name, context.new_version 64 | ) 65 | else: 66 | log.info('Dry run, skipping package upload') 67 | 68 | 69 | def install_from_pypi(context): 70 | """Attempts to install your package from pypi.""" 71 | 72 | tmp_dir = venv.create_venv() 73 | install_cmd = '%s/bin/pip install %s' % (tmp_dir, context.module_name) 74 | 75 | package_index = 'pypi' 76 | if context.pypi: 77 | install_cmd += '-i %s' % context.pypi 78 | package_index = context.pypi 79 | 80 | try: 81 | result = shell.dry_run(install_cmd, context.dry_run) 82 | if not context.dry_run and not result: 83 | log.error( 84 | 'Failed to install %s from %s', context.module_name, package_index 85 | ) 86 | else: 87 | log.info( 88 | 'Successfully installed %s from %s', context.module_name, package_index 89 | ) 90 | 91 | except Exception as e: 92 | error_msg = 'Error installing %s from %s' % (context.module_name, package_index) 93 | log.exception(error_msg) 94 | raise Exception(error_msg, e) 95 | -------------------------------------------------------------------------------- /changes/probe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from os.path import exists 3 | 4 | from plumbum import local 5 | from plumbum.cmd import git 6 | from plumbum.commands import CommandNotFound 7 | 8 | from changes import attributes, exceptions 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | TOOLS = ['git', 'diff', 'python'] 14 | 15 | TEST_RUNNERS = ['pytest', 'nose', 'tox'] 16 | 17 | README_EXTENSIONS = [ 18 | '.md', 19 | '.rst', 20 | '.txt', 21 | '' '.wiki', 22 | '.rdoc', 23 | '.org', 24 | '.pod', 25 | '.creole', 26 | '.textile', 27 | ] 28 | 29 | 30 | def report_and_raise(probe_name, probe_result, failure_msg): 31 | """Logs the probe result and raises on failure""" 32 | log.info('%s? %s' % (probe_name, probe_result)) 33 | if not probe_result: 34 | raise exceptions.ProbeException(failure_msg) 35 | else: 36 | return True 37 | 38 | 39 | def has_setup(): 40 | """`setup.py`""" 41 | return report_and_raise( 42 | 'Has a setup.py', exists('setup.py'), 'Your project needs a setup.py' 43 | ) 44 | 45 | 46 | def has_binary(command): 47 | try: 48 | local.which(command) 49 | return True 50 | except CommandNotFound: 51 | log.info('%s does not exist' % command) 52 | return False 53 | 54 | 55 | def has_tools(): 56 | return any([has_binary(tool) for tool in TOOLS]) 57 | 58 | 59 | def has_test_runner(): 60 | return any([has_binary(runner) for runner in TEST_RUNNERS]) 61 | 62 | 63 | def has_changelog(): 64 | """CHANGELOG.md""" 65 | return report_and_raise( 66 | 'CHANGELOG.md', exists('CHANGELOG.md'), 'Create a CHANGELOG.md file' 67 | ) 68 | 69 | 70 | def has_readme(): 71 | """README""" 72 | return report_and_raise( 73 | 'README', 74 | any([exists('README{}'.format(ext)) for ext in README_EXTENSIONS]), 75 | 'Create a (valid) README', 76 | ) 77 | 78 | 79 | def has_metadata(python_module): 80 | """`/__init__.py` with `__version__` and `__url__`""" 81 | init_path = '{}/__init__.py'.format(python_module) 82 | has_metadata = ( 83 | exists(init_path) 84 | and attributes.has_attribute(python_module, '__version__') 85 | and attributes.has_attribute(python_module, '__url__') 86 | ) 87 | return report_and_raise( 88 | 'Has module metadata', 89 | has_metadata, 90 | 'Your %s/__init__.py must contain __version__ and __url__ attributes', 91 | ) 92 | 93 | 94 | def has_signing_key(context): 95 | return 'signingkey' in git('config', '-l') 96 | 97 | 98 | def probe_project(python_module): 99 | """ 100 | Check if the project meets `changes` requirements. 101 | Complain and exit otherwise. 102 | """ 103 | log.info('Checking project for changes requirements.') 104 | return ( 105 | has_tools() 106 | and has_setup() 107 | and has_metadata(python_module) 108 | and has_test_runner() 109 | and has_readme() 110 | and has_changelog() 111 | ) 112 | -------------------------------------------------------------------------------- /changes/prompt.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import click 4 | 5 | from changes.commands import error, note 6 | 7 | 8 | def choose_labels(alternatives): 9 | """ 10 | Prompt the user select several labels from the provided alternatives. 11 | 12 | At least one label must be selected. 13 | 14 | :param list alternatives: Sequence of options that are available to select from 15 | :return: Several selected labels 16 | """ 17 | if not alternatives: 18 | raise ValueError 19 | 20 | if not isinstance(alternatives, list): 21 | raise TypeError 22 | 23 | choice_map = OrderedDict( 24 | ('{}'.format(i), value) for i, value in enumerate(alternatives, 1) 25 | ) 26 | # prepend a termination option 27 | input_terminator = '0' 28 | choice_map.update({input_terminator: ''}) 29 | choice_map.move_to_end('0', last=False) 30 | 31 | choice_indexes = choice_map.keys() 32 | 33 | choice_lines = ['{} - {}'.format(*c) for c in choice_map.items()] 34 | prompt = '\n'.join( 35 | ( 36 | 'Select labels:', 37 | '\n'.join(choice_lines), 38 | 'Choose from {}'.format(', '.join(choice_indexes)), 39 | ) 40 | ) 41 | 42 | user_choices = set() 43 | user_choice = None 44 | 45 | while not user_choice == input_terminator: 46 | if user_choices: 47 | note('Selected labels: [{}]'.format(', '.join(user_choices))) 48 | 49 | user_choice = click.prompt( 50 | prompt, type=click.Choice(choice_indexes), default=input_terminator 51 | ) 52 | done = user_choice == input_terminator 53 | new_selection = user_choice not in user_choices 54 | nothing_selected = not user_choices 55 | 56 | if not done and new_selection: 57 | user_choices.add(choice_map[user_choice]) 58 | 59 | if done and nothing_selected: 60 | error('Please select at least one label') 61 | user_choice = None 62 | 63 | return user_choices 64 | -------------------------------------------------------------------------------- /changes/services.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import attr 4 | import requests 5 | import uritemplate 6 | 7 | EXT_TO_MIME_TYPE = { 8 | '.gz': 'application/x-gzip', 9 | '.whl': 'application/zip', 10 | '.zip': 'application/zip', 11 | } 12 | 13 | 14 | @attr.s 15 | class GitHub(object): 16 | ISSUE_ENDPOINT = 'https://api.github.com/repos{/owner}{/repo}/issues{/number}' 17 | LABELS_ENDPOINT = 'https://api.github.com/repos{/owner}{/repo}/labels' 18 | RELEASES_ENDPOINT = 'https://api.github.com/repos{/owner}{/repo}/releases' 19 | 20 | repository = attr.ib() 21 | 22 | @property 23 | def owner(self): 24 | return self.repository.owner 25 | 26 | @property 27 | def repo(self): 28 | return self.repository.repo 29 | 30 | @property 31 | def auth_token(self): 32 | return self.repository.auth_token 33 | 34 | @property 35 | def headers(self): 36 | # TODO: requests.Session 37 | return {'Authorization': 'token {}'.format(self.auth_token)} 38 | 39 | def pull_request(self, pr_num): 40 | pull_request_api_url = uritemplate.expand( 41 | self.ISSUE_ENDPOINT, dict(owner=self.owner, repo=self.repo, number=pr_num) 42 | ) 43 | 44 | return requests.get(pull_request_api_url, headers=self.headers).json() 45 | 46 | def labels(self): 47 | labels_api_url = uritemplate.expand( 48 | self.LABELS_ENDPOINT, dict(owner=self.owner, repo=self.repo) 49 | ) 50 | 51 | return requests.get(labels_api_url, headers=self.headers).json() 52 | 53 | def create_release(self, release, uploads=None): 54 | params = { 55 | 'tag_name': release.version, 56 | 'name': release.name, 57 | 'body': release.description, 58 | # 'prerelease': True, 59 | } 60 | 61 | releases_api_url = uritemplate.expand( 62 | self.RELEASES_ENDPOINT, dict(owner=self.owner, repo=self.repo) 63 | ) 64 | 65 | response = requests.post( 66 | releases_api_url, headers=self.headers, json=params 67 | ).json() 68 | 69 | upload_url = response['upload_url'] 70 | upload_responses = ( 71 | [self.create_upload(upload_url, Path(upload)) for upload in uploads] 72 | if uploads 73 | else [] 74 | ) 75 | 76 | return response, upload_responses 77 | 78 | def create_upload(self, upload_url, upload_path): 79 | requests.post( 80 | uritemplate.expand(upload_url, {'name': upload_path.name}), 81 | headers=dict( 82 | **self.headers, **{'content-type': EXT_TO_MIME_TYPE[upload_path.ext]} 83 | ), 84 | data=upload_path.read_bytes(), 85 | verify=False, 86 | ) 87 | -------------------------------------------------------------------------------- /changes/shell.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from plumbum import local 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | 8 | def dry_run(command, dry_run): 9 | """Executes a shell command unless the dry run option is set""" 10 | if not dry_run: 11 | cmd_parts = command.split(' ') 12 | # http://plumbum.readthedocs.org/en/latest/local_commands.html#run-and-popen 13 | return local[cmd_parts[0]](cmd_parts[1:]) 14 | else: 15 | log.info('Dry run of %s, skipping' % command) 16 | return True 17 | -------------------------------------------------------------------------------- /changes/templates/release_notes_template.md: -------------------------------------------------------------------------------- 1 | # {{ release.title }} 2 | {{ release.description or '' }} 3 | {%- for label, properties in release.notes.items() %} 4 | {%- if properties.pull_requests %} 5 | ## {{ properties.description }} 6 | {%- for pull_request in properties.pull_requests %} 7 | * #{{ pull_request.number }} {{ pull_request.title }} 8 | {%- endfor %} 9 | {% endif %}{% endfor %} 10 | -------------------------------------------------------------------------------- /changes/util.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import tempfile 3 | from shutil import rmtree 4 | 5 | 6 | def extract(dictionary, keys): 7 | """ 8 | Extract only the specified keys from a dict 9 | 10 | :param dictionary: source dictionary 11 | :param keys: list of keys to extract 12 | :return dict: extracted dictionary 13 | """ 14 | return dict((k, dictionary[k]) for k in keys if k in dictionary) 15 | 16 | 17 | def extract_arguments(arguments, long_keys, key_prefix='--'): 18 | """ 19 | :param arguments: dict of command line arguments 20 | 21 | """ 22 | long_arguments = extract(arguments, long_keys) 23 | return dict( 24 | [(key.replace(key_prefix, ''), value) for key, value in long_arguments.items()] 25 | ) 26 | 27 | 28 | @contextlib.contextmanager 29 | def mktmpdir(): 30 | tmp_dir = tempfile.mkdtemp() 31 | try: 32 | yield tmp_dir 33 | finally: 34 | rmtree(tmp_dir) 35 | -------------------------------------------------------------------------------- /changes/vcs.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | 4 | import click 5 | import requests 6 | from uritemplate import expand 7 | 8 | from changes import probe, shell 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | COMMIT_TEMPLATE = 'git commit --message="%s" %s/__init__.py CHANGELOG.md' 13 | TAG_TEMPLATE = 'git tag %s %s --message="%s"' 14 | 15 | EXT_TO_MIME_TYPE = { 16 | '.gz': 'application/x-gzip', 17 | '.whl': 'application/zip', 18 | '.zip': 'application/zip', 19 | } 20 | 21 | 22 | def commit_version_change(context): 23 | # TODO: signed commits? 24 | shell.dry_run( 25 | COMMIT_TEMPLATE % (context.new_version, context.module_name), context.dry_run 26 | ) 27 | shell.dry_run('git push', context.dry_run) 28 | 29 | 30 | def tag_and_push(context): 31 | """Tags your git repo with the new version number""" 32 | tag_option = '--annotate' 33 | if probe.has_signing_key(context): 34 | tag_option = '--sign' 35 | 36 | shell.dry_run( 37 | TAG_TEMPLATE % (tag_option, context.new_version, context.new_version), 38 | context.dry_run, 39 | ) 40 | 41 | shell.dry_run('git push --tags', context.dry_run) 42 | 43 | 44 | def create_github_release(context, gh_token, description): 45 | 46 | params = { 47 | 'tag_name': context.new_version, 48 | 'name': description, 49 | 'body': ''.join(context.changelog_content), 50 | 'prerelease': True, 51 | } 52 | 53 | response = requests.post( 54 | 'https://api.github.com/repos/{owner}/{repo}/releases'.format( 55 | owner=context.owner, repo=context.repo 56 | ), 57 | auth=(gh_token, 'x-oauth-basic'), 58 | json=params, 59 | ).json() 60 | 61 | click.echo('Created release {response}'.format(response=response)) 62 | return response['upload_url'] 63 | 64 | 65 | def upload_release_distributions(context, gh_token, distributions, upload_url): 66 | for distribution in distributions: 67 | click.echo( 68 | 'Uploading {distribution} to {upload_url}'.format( 69 | distribution=distribution, upload_url=upload_url 70 | ) 71 | ) 72 | response = requests.post( 73 | expand(upload_url, dict(name=distribution.name)), 74 | auth=(gh_token, 'x-oauth-basic'), 75 | headers={'content-type': EXT_TO_MIME_TYPE[distribution.ext]}, 76 | data=io.open(distribution, mode='rb'), 77 | verify=False, 78 | ) 79 | click.echo('Upload response: {response}'.format(response=response)) 80 | -------------------------------------------------------------------------------- /changes/venv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from plumbum import local 5 | from plumbum.cmd import virtualenv 6 | 7 | 8 | def create_venv(tmp_dir=None): 9 | if not tmp_dir: 10 | tmp_dir = tempfile.mkdtemp() 11 | virtualenv(tmp_dir) 12 | return tmp_dir 13 | 14 | 15 | def install(package_name, venv_dir): 16 | if not os.path.exists(venv_dir): 17 | venv_dir = create_venv() 18 | pip = '%s/bin/pip' % venv_dir 19 | local[pip]('install', package_name) 20 | -------------------------------------------------------------------------------- /changes/verification.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from plumbum import CommandNotFound, local 4 | 5 | from changes import shell 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def get_test_runner(): 11 | test_runners = ['tox', 'nosetests', 'py.test'] 12 | test_runner = None 13 | for runner in test_runners: 14 | try: 15 | test_runner = local[runner] 16 | except CommandNotFound: 17 | continue 18 | return test_runner 19 | 20 | 21 | def run_tests(): 22 | """Executes your tests.""" 23 | test_runner = get_test_runner() 24 | if test_runner: 25 | result = test_runner() 26 | log.info('Test execution returned:\n%s' % result) 27 | return result 28 | else: 29 | log.info('No test runner found') 30 | 31 | return None 32 | 33 | 34 | def run_test_command(context): 35 | if context.test_command: 36 | result = shell.dry_run(context.test_command, context.dry_run) 37 | log.info('Test command "%s", returned %s', context.test_command, result) 38 | return True 39 | -------------------------------------------------------------------------------- /changes/version.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function, unicode_literals 2 | 3 | import logging 4 | 5 | import click 6 | import semantic_version 7 | 8 | from changes import attributes 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def current_version(module_name): 14 | return attributes.extract_attribute(module_name, '__version__') 15 | 16 | 17 | def get_new_version( 18 | module_name, current_version, no_input, major=False, minor=False, patch=False 19 | ): 20 | 21 | proposed_new_version = increment( 22 | current_version, major=major, minor=minor, patch=patch 23 | ) 24 | 25 | if no_input: 26 | new_version = proposed_new_version 27 | else: 28 | new_version = click.prompt( 29 | 'What is the release version for "{0}" '.format(module_name), 30 | default=proposed_new_version, 31 | ) 32 | return new_version.strip() 33 | 34 | 35 | def increment(version, major=False, minor=False, patch=True): 36 | """ 37 | Increment a semantic version 38 | 39 | :param version: str of the version to increment 40 | :param major: bool specifying major level version increment 41 | :param minor: bool specifying minor level version increment 42 | :param patch: bool specifying patch level version increment 43 | :return: str of the incremented version 44 | """ 45 | version = semantic_version.Version(version) 46 | if major: 47 | version.major += 1 48 | version.minor = 0 49 | version.patch = 0 50 | elif minor: 51 | version.minor += 1 52 | version.patch = 0 53 | elif patch: 54 | version.patch += 1 55 | 56 | return str(version) 57 | 58 | 59 | def increment_version(context): 60 | """Increments the __version__ attribute of your module's __init__.""" 61 | 62 | attributes.replace_attribute( 63 | context.module_name, '__version__', context.new_version, dry_run=context.dry_run 64 | ) 65 | log.info( 66 | 'Bumped version from %s to %s' % (context.current_version, context.new_version) 67 | ) 68 | -------------------------------------------------------------------------------- /docs/_snippets/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/_snippets/shoulders.md: -------------------------------------------------------------------------------- 1 | 2 | ## 🌟 Built with 3 | 4 | * [tox] - standardize testing in Python 5 | * [poetry] - Python dependency management and packaging made easy 6 | * [pytest] - helps you write better programs 7 | * [pre-commit] - A framework for managing and maintaining multi-language pre-commit hooks. 8 | * [mkdocs] - Project documentation with Markdown. 9 | 10 | * [click] - A Python package for creating beautiful command line interfaces in a composable way with as little code as necessary 11 | * [requests] - Requests is the only Non-GMO HTTP library for Python, safe for human consumption 12 | 13 | 14 | [tox]: https://tox.readthedocs.io/en/latest/ 15 | [poetry]: https://poetry.eustace.io/ 16 | [pytest]: https://docs.pytest.org/en/latest/ 17 | [pre-commit]: https://pre-commit.com/ 18 | [mkdocs]: https://www.mkdocs.org 19 | 20 | [click]: https://click.palletsprojects.com/en/7.x/ 21 | [requests]: https://2.python-requests.org/en/master/ -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | ## Activity Diagrams 4 | 5 | ### changes stage 6 | 7 | ```mermaid 8 | sequenceDiagram 9 | participant stage as 🖥🤓changes stage 10 | participant git as 🗄 git 11 | participant gh as 🌐 api.github.com 12 | 13 | stage->>gh: releases_from_pull_requests 14 | gh->>stage: Release(version) 15 | 16 | stage->>stage: bumpversion and generate release note 17 | ``` 18 | 19 | ### changes publish 20 | 21 | ```mermaid 22 | sequenceDiagram 23 | participant publish as 🖥🤓publish 24 | participant git as 🗄 git 25 | participant gh as 🌐 api.github.com 26 | 27 | publish->>gh: releases_from_pull_requests 28 | 29 | publish->>git: git add [bumpversion.files, .md] 30 | publish->>git: git commit [bumpversion.files, CHANGELOG.md] 31 | publish->>git: git tag release.version 32 | publish->>git: git push --tags 33 | 34 | publish->>gh: 🚀 github release 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 2 | --8<-- "README.md" 3 | 4 | ## 📖 CLI Reference 5 | 6 | ::: mkdocs-click 7 | :module: changes.cli 8 | :command: status 9 | :depth: 2 10 | :style: table 11 | 12 | ::: mkdocs-click 13 | :module: changes.cli 14 | :command: stage 15 | :depth: 2 16 | :style: table 17 | 18 | ::: mkdocs-click 19 | :module: changes.cli 20 | :command: publish 21 | :depth: 2 22 | :style: table 23 | 24 | --8<-- "shoulders.md" 25 | 26 | [pipx]: https://pipxproject.github.io/pipx/ 27 | -------------------------------------------------------------------------------- /docs/media: -------------------------------------------------------------------------------- 1 | ../media/ -------------------------------------------------------------------------------- /docs/tests.md: -------------------------------------------------------------------------------- 1 | # Test Report 2 | 3 | *Report generated on 05-Jun-2021 at 16:30:23 by [pytest-md]* 4 | 5 | [pytest-md]: https://github.com/hackebrot/pytest-md 6 | 7 | ## Summary 8 | 9 | 44 tests ran in 7.12 seconds 10 | 11 | - 33 passed 12 | - 11 skipped 13 | 14 | ## 33 passed 15 | 16 | ### tests/test_changelog.py 17 | 18 | `test_write_new_changelog` 0.00s 19 | 20 | `test_replace_sha_with_commit_link` 0.00s 21 | 22 | ### tests/test_cli.py 23 | 24 | `test_version` 0.00s 25 | 26 | ### tests/test_init.py 27 | 28 | `test_init_prompts_for_auth_token_and_writes_tool_config` 0.06s 29 | 30 | `test_init_finds_auth_token_in_environment` 0.07s 31 | 32 | ### tests/test_packaging.py 33 | 34 | `test_build_distributions` 0.00s 35 | 36 | `test_install_package` 0.00s 37 | 38 | `test_upload_package` 0.00s 39 | 40 | `test_install_from_pypi` 0.72s 41 | 42 | ### tests/test_probe.py 43 | 44 | `test_probe_project` 0.00s 45 | 46 | `test_has_binary` 0.00s 47 | 48 | `test_has_no_binary` 0.00s 49 | 50 | `test_has_test_runner` 0.00s 51 | 52 | `test_accepts_readme` 0.00s 53 | 54 | `test_refuses_readme` 0.01s 55 | 56 | `test_fails_for_missing_readme` 0.00s 57 | 58 | ### tests/test_publish.py 59 | 60 | `test_publish_no_staged_release` 0.08s 61 | 62 | `test_publish` 0.54s 63 | 64 | ### tests/test_repository.py 65 | 66 | `test_repository_parses_remote_url` 0.03s 67 | 68 | `test_repository_parses_versions` 0.02s 69 | 70 | `test_latest_version` 0.04s 71 | 72 | ### tests/test_shell.py 73 | 74 | `test_handle_dry_run` 0.01s 75 | 76 | `test_handle_dry_run_true` 0.00s 77 | 78 | ### tests/test_stage.py 79 | 80 | `test_stage_draft` 0.34s 81 | 82 | `test_stage` 0.33s 83 | 84 | `test_stage_discard` 0.42s 85 | 86 | `test_stage_discard_nothing_staged` 0.07s 87 | 88 | ### tests/test_status.py 89 | 90 | `test_status` 0.14s 91 | 92 | `test_status_with_changes` 0.27s 93 | 94 | ### tests/test_util.py 95 | 96 | `test_extract` 0.00s 97 | 98 | `test_extract_arguments` 0.00s 99 | 100 | ### tests/test_vcs.py 101 | 102 | `test_commit_version_change` 0.01s 103 | 104 | `test_tag_and_push` 0.01s 105 | 106 | ## 11 skipped 107 | 108 | ### tests/test_attributes.py 109 | 110 | `test_extract_attribute` 0.00s 111 | 112 | `test_replace_attribute` 0.00s 113 | 114 | `test_replace_attribute_dry_run` 0.00s 115 | 116 | `test_has_attribute` 0.00s 117 | 118 | ### tests/test_changelog.py 119 | 120 | `test_generate_changelog` 0.00s 121 | 122 | ### tests/test_vcs.py 123 | 124 | `test_github_release` 0.00s 125 | 126 | `test_upload_release_distributions` 0.00s 127 | 128 | `test_signed_tag` 0.00s 129 | 130 | ### tests/test_version.py 131 | 132 | `test_increment` 0.00s 133 | 134 | `test_current_version` 0.00s 135 | 136 | `test_get_new_version` 0.00s 137 | -------------------------------------------------------------------------------- /media/changes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeljoseph/changes/81dfa6b44041d124c3c6195bb8dce926138242bf/media/changes.png -------------------------------------------------------------------------------- /media/demo.svg: -------------------------------------------------------------------------------- 1 | /Users/michael/Source/changesλchangesλchangesstatusStatus[michaeljoseph/changes]...Repository:michaeljoseph/changes...LatestVersion...0.7.0Changes...38changesfoundsince0.7.0#164Updatepytestto3.4.1by@pyup-bot#163Updatepytest-mockto1.7.0by@pyup-bot#161Updateplumbumto1.6.6by@pyup-bot#160Updatesphinx-bootstrap-themeto0.6.4by@pyup-bot#162Updatecodecovto2.0.15by@pyup-bot#154Updateautopep8to1.3.4by@pyup-bot#157Updatepytestto3.4.0by@pyup-bot#155Updateinvoketo0.22.1by@pyup-bot#149Updatesphinxcontrib-httpdomainto1.6.0by@pyup-bot#159Refactorgitusageby@michaeljoseph[hygiene]#146Updatetomlto0.9.4by@pyup-bot#145Updatecodecovto2.0.13by@pyup-bot#141Updateattrsto17.4.0by@pyup-bot#140Updateplumbumto1.6.5by@pyup-bot#135Updateinvoketo0.22.0by@pyup-bot#137Updatecodecovto2.0.10by@pyup-bot#133Updateplumbumto1.6.4by@pyup-bot#131Lintingby@michaeljoseph[hygiene]#130Updatepytestto3.2.5by@pyup-bot#127Refactorandpolishby@michaeljoseph[hygiene]#128Updateattrsto17.3.0by@pyup-bot#126publish:commit,tagandpublishreleaseby@michaeljoseph[enhancement]#125stage:discardreleasemodificationsby@michaeljoseph[enhancement]#122stage:draftreleasenotesby@michaeljoseph[enhancement]#121InitialUpdateby@pyup-bot[hygiene]#120Stagereleasedocumentationandversionincrementby@michaeljoseph[enhancement]#119Updatesappveyortestcommandandpruneversionsby@michaeljoseph[hygiene]#118Statuscommandby@michaeljoseph[enhancement]#117💥Initcommandby@michaeljoseph[breaking-change,enhancement]#116FixtypoinCLIoptiondescriptionsby@getconor[docs]#111WindowssupportwithAppveyorCIby@michaeljoseph[enhancement]#114Updaterequirementsby@michaeljoseph[hygiene]#112Fixbrokenlinkby@michaelaye[docs]#110Signgittagsby@michaeljoseph[enhancement]#108Fixtypographicalerror(s)by@orthographic-pedant[docs]#107Readmeupdatesby@michaeljoseph[docs]#105CircleCIby@michaeljoseph[enhancement]#106SupportforothermarkupextensionsforREADMEfilesby@goldsborough[enhancement]Computedreleasetypefeaturefromchangesissuetags...Proposedversionbump0.7.0=>0.8.0...λλcλchλchaλchanλchangλchangeλchangessλchangesstaλchangesstatλchangesstatu -------------------------------------------------------------------------------- /media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeljoseph/changes/81dfa6b44041d124c3c6195bb8dce926138242bf/media/favicon.ico -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: ♻️ changes 2 | 3 | theme: 4 | name: material 5 | features: 6 | - navigation.tabs 7 | - navigation.instant 8 | favicon: media/favicon.ico 9 | icon: 10 | logo: fontawesome/solid/recycle 11 | 12 | nav: 13 | - Changes: index.md 14 | - Design: design.md 15 | - Test Report: tests.md 16 | 17 | markdown_extensions: 18 | - codehilite: 19 | - mkdocs-click: 20 | - pymdownx.emoji: 21 | emoji_index: !!python/name:materialx.emoji.twemoji 22 | emoji_generator: !!python/name:materialx.emoji.to_svg 23 | - pymdownx.details: 24 | - pymdownx.highlight: 25 | css_class: codehilite 26 | - pymdownx.magiclink: 27 | - pymdownx.snippets: 28 | base_path: docs/_snippets 29 | check_paths: true 30 | - pymdownx.superfences: 31 | custom_fences: 32 | - name: mermaid 33 | class: mermaid 34 | format: !!python/name:pymdownx.superfences.fence_div_format 35 | - pymdownx.tabbed: 36 | - toc: 37 | permalink: link 38 | 39 | extra_css: 40 | - https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.0.9/mermaid.neutral.css 41 | extra_javascript: 42 | - https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.0.9/mermaid.min.js 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "changes" 3 | version = "0.7.0" 4 | description = "changes manages software project releases" 5 | readme = "README.md" 6 | authors = ["Michael Joseph "] 7 | repository = "https://github.com/michaeljoseph/changes" 8 | homepage = "https://changes.readthedocs.io/en/latest/" 9 | license = "MIT" 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | ] 20 | packages = [ 21 | { include = "changes" } 22 | ] 23 | include = [ 24 | "README.md", 25 | "CHANGELOG.md", 26 | "LICENSE", 27 | "tests/**/*", 28 | "docs/**/*", 29 | ] 30 | 31 | [tool.poetry.scripts] 32 | changes = "changes.cli:main" 33 | 34 | [tool.poetry.dependencies] 35 | python = ">=3.7" 36 | click = "^7." 37 | plumbum = "^1.6" 38 | requests = "^2.22" 39 | "giturlparse.py" = "^0.0.5" 40 | semantic_version = "^2.6" 41 | uritemplate = "^3.0" 42 | bumpversion = "^0.5.3" 43 | attrs = "^19.1" 44 | requests-cache = "^0.5.0" 45 | inflection = "^0.3.1" 46 | mkdocs-click = "^0.4.0" 47 | pip = "^21.1.2" 48 | 49 | [tool.poetry.dev-dependencies] 50 | pytest = "^5.0" 51 | pytest-cov = "^2.7" 52 | pytest-mock = "^1.10" 53 | pytest-watch = "^4.2" 54 | responses = "^0.10.6" 55 | haikunator = "^2.1" 56 | pre-commit = "^1.18" 57 | mkdocs = "^1.0" 58 | pdoc3 = "^0.6.3" 59 | pymdown-extensions = "^8.0" 60 | mkdocs-material = "^7.0" 61 | markdown_include = "^0.5.1" 62 | pytest-html = "^1.22" 63 | pytest-md = "^0.2.0" 64 | 65 | [tool.black] 66 | line-length = 88 67 | skip-string-normalization = "True" 68 | 69 | [build-system] 70 | requires = ["poetry>=0.12"] 71 | build-backend = "poetry.masonry.api" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | poetry 2 | tox 3 | pre-commit -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from changes.config import Config 2 | 3 | module_name = 'test_app' 4 | context = Config( 5 | module_name, 6 | True, 7 | True, 8 | True, 9 | '%s/requirements.txt' % module_name, 10 | '0.0.2', 11 | '0.0.1', 12 | 'https://github.com/someuser/test_app', 13 | None, 14 | ) 15 | context.gh_token = 'foo' 16 | context.requirements = '%s/requirements.txt' % module_name 17 | context.tmp_file = '%s/__init__.py' % module_name 18 | context.initial_init_content = [ 19 | '"""A test app"""', 20 | '', 21 | "__version__ = '0.0.1'", 22 | "__url__ = 'https://github.com/someuser/test_app'", 23 | "__author__ = 'Some User'", 24 | "__email__ = 'someuser@gmail.com'", 25 | ] 26 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | import textwrap 4 | from pathlib import Path 5 | 6 | import pytest 7 | from click.testing import CliRunner 8 | from plumbum.cmd import git 9 | 10 | import changes 11 | from changes import compat 12 | 13 | pytest_plugins = 'pytester' 14 | 15 | # TODO: textwrap.dedent.heredoc 16 | INIT_CONTENT = [ 17 | '"""A test app"""', 18 | '', 19 | "__version__ = '0.0.1'", 20 | "__url__ = 'https://github.com/someuser/test_app'", 21 | "__author__ = 'Some User'", 22 | "__email__ = 'someuser@gmail.com'", 23 | ] 24 | SETUP_PY = ['from setuptools import setup', "setup(name='test_app'"] 25 | README_MARKDOWN = ['# Test App', '', 'This is the test application.'] 26 | 27 | PYTHON_MODULE = 'test_app' 28 | 29 | PYTHON_PROJECT_CONTENT = { 30 | '%s/__init__.py' % PYTHON_MODULE: INIT_CONTENT, 31 | 'setup.py': SETUP_PY, 32 | 'requirements.txt': ['pytest'], 33 | } 34 | 35 | FILE_CONTENT = { 36 | 'version.txt': ['0.0.1'], 37 | 'README.md': README_MARKDOWN, 38 | 'CHANGELOG.md': [''], 39 | } 40 | 41 | AUTH_TOKEN_ENVVAR = 'GITHUB_AUTH_TOKEN' 42 | 43 | BUG_LABEL_JSON = [ 44 | { 45 | 'id': 52048163, 46 | 'url': 'https://api.github.com/repos/michaeljoseph/changes/labels/bug', 47 | 'name': 'bug', 48 | 'color': 'fc2929', 49 | 'default': True, 50 | } 51 | ] 52 | 53 | ISSUE_URL = 'https://api.github.com/repos/michaeljoseph/test_app/issues/111' 54 | PULL_REQUEST_JSON = { 55 | 'number': 111, 56 | 'title': 'The title of the pull request', 57 | 'body': 'An optional, longer description.', 58 | 'user': {'login': 'michaeljoseph'}, 59 | 'labels': [{'id': 1, 'name': 'bug'}], 60 | 'url': 'https://api.github.com/repos/michaeljoseph/test_app/issues/111', 61 | } 62 | 63 | LABEL_URL = 'https://api.github.com/repos/michaeljoseph/test_app/labels' 64 | BUG_LABEL_JSON = [ 65 | { 66 | 'id': 52048163, 67 | 'url': 'https://api.github.com/repos/michaeljoseph/test_app/labels/bug', 68 | 'name': 'bug', 69 | 'color': 'fc2929', 70 | 'default': True, 71 | } 72 | ] 73 | 74 | RELEASES_URL = 'https://api.github.com/repos/michaeljoseph/test_app/releases' 75 | 76 | 77 | @pytest.fixture 78 | def git_repo(tmpdir): 79 | with CliRunner().isolated_filesystem() as repo_dir: 80 | for file_path, content in FILE_CONTENT.items(): 81 | open(file_path, 'w').write('\n'.join(content)) 82 | 83 | git('init') 84 | git(shlex.split('config --local user.email "you@example.com"')) 85 | git(shlex.split('config --local user.name "Your Name"')) 86 | git( 87 | shlex.split( 88 | 'remote add origin https://github.com/michaeljoseph/test_app.git' 89 | ) 90 | ) 91 | 92 | tmp_push_repo = Path(str(tmpdir)) 93 | git('init', '--bare', str(tmp_push_repo)) 94 | git( 95 | shlex.split( 96 | 'remote set-url --push origin {}'.format(tmp_push_repo.as_uri()) 97 | ) 98 | ) 99 | 100 | git('add', [file for file in FILE_CONTENT.keys()]) 101 | 102 | git('commit', '-m', 'Initial commit') 103 | git(shlex.split('tag 0.0.1')) 104 | 105 | yield repo_dir 106 | 107 | 108 | @pytest.fixture 109 | def python_module(git_repo): 110 | os.mkdir(PYTHON_MODULE) 111 | 112 | for file_path, content in PYTHON_PROJECT_CONTENT.items(): 113 | open(file_path, 'w').write('\n'.join(content)) 114 | 115 | git('add', [file for file in PYTHON_PROJECT_CONTENT.keys()]) 116 | git('commit', '-m', 'Python project initialisation') 117 | 118 | yield 119 | 120 | 121 | def github_merge_commit(pull_request_number): 122 | from haikunator import Haikunator 123 | 124 | branch_name = Haikunator().haikunate() 125 | commands = [ 126 | 'checkout -b {}'.format(branch_name), 127 | 'commit --allow-empty -m "Test branch commit message"', 128 | 'checkout master', 129 | 'merge --no-ff {}'.format(branch_name), 130 | 'commit --allow-empty --amend -m ' 131 | '"Merge pull request #{} from test_app/{}"'.format( 132 | pull_request_number, branch_name 133 | ), 134 | ] 135 | for command in commands: 136 | git(shlex.split(command)) 137 | 138 | 139 | # prompts_for_tool_configuration 140 | @pytest.fixture 141 | def with_releases_directory_and_bumpversion_file_prompt(mocker): 142 | prompt = mocker.patch('changes.config.click.prompt', autospec=True) 143 | prompt.side_effect = [ 144 | # release_directory 145 | 'docs/releases', 146 | # bumpversion files 147 | 'version.txt', 148 | # quit prompt 149 | '.', 150 | # label descriptions 151 | # 'Features', 152 | # 'Bug Fixes' 153 | ] 154 | 155 | prompt = mocker.patch('changes.config.prompt.choose_labels', autospec=True) 156 | prompt.return_value = ['bug'] 157 | 158 | 159 | @pytest.fixture 160 | def with_auth_token_prompt(mocker): 161 | mocker.patch('changes.config.click.launch') 162 | 163 | prompt = mocker.patch('changes.config.click.prompt') 164 | prompt.return_value = 'foo' 165 | 166 | saved_token = None 167 | if os.environ.get(AUTH_TOKEN_ENVVAR): 168 | saved_token = os.environ[AUTH_TOKEN_ENVVAR] 169 | del os.environ[AUTH_TOKEN_ENVVAR] 170 | 171 | yield 172 | 173 | if saved_token: 174 | os.environ[AUTH_TOKEN_ENVVAR] = saved_token 175 | 176 | 177 | @pytest.fixture 178 | def with_auth_token_envvar(): 179 | saved_token = None 180 | if os.environ.get(AUTH_TOKEN_ENVVAR): 181 | saved_token = os.environ[AUTH_TOKEN_ENVVAR] 182 | 183 | os.environ[AUTH_TOKEN_ENVVAR] = 'foo' 184 | 185 | yield 186 | 187 | if saved_token: 188 | os.environ[AUTH_TOKEN_ENVVAR] = saved_token 189 | else: 190 | del os.environ[AUTH_TOKEN_ENVVAR] 191 | 192 | 193 | @pytest.fixture 194 | def changes_config_in_tmpdir(monkeypatch, tmpdir): 195 | changes_config_file = Path(str(tmpdir.join('.changes'))) 196 | monkeypatch.setattr( 197 | changes.config, 198 | 'expandvars' if compat.IS_WINDOWS else 'expanduser', 199 | lambda x: str(changes_config_file), 200 | ) 201 | assert not changes_config_file.exists() 202 | return changes_config_file 203 | 204 | 205 | @pytest.fixture 206 | def configured(git_repo, changes_config_in_tmpdir): 207 | changes_config_in_tmpdir.write_text( 208 | textwrap.dedent( 209 | """\ 210 | [changes] 211 | auth_token = "foo" 212 | """ 213 | ) 214 | ) 215 | 216 | Path('.changes.toml').write_text( 217 | textwrap.dedent( 218 | """\ 219 | [changes] 220 | releases_directory = "docs/releases" 221 | 222 | [changes.labels.bug] 223 | default = true 224 | id = 208045946 225 | url = "https://api.github.com/repos/michaeljoseph/test_app/labels/bug" 226 | name = "bug" 227 | description = "Bug" 228 | color = "f29513" 229 | """ 230 | ) 231 | ) 232 | 233 | Path('.bumpversion.cfg').write_text( 234 | textwrap.dedent( 235 | """\ 236 | [bumpversion] 237 | current_version = 0.0.1 238 | 239 | [bumpversion:file:version.txt] 240 | """ 241 | ) 242 | ) 243 | 244 | for file_to_add in ['.changes.toml', '.bumpversion.cfg']: 245 | git('add', file_to_add) 246 | git('commit', '-m', 'Add changes configuration files') 247 | 248 | return str(changes_config_in_tmpdir) 249 | -------------------------------------------------------------------------------- /tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from changes import attributes 4 | 5 | from . import context 6 | 7 | 8 | @pytest.mark.skip('bumpversion') 9 | def test_extract_attribute(python_module): 10 | assert '0.0.1' == attributes.extract_attribute('test_app', '__version__') 11 | 12 | 13 | @pytest.mark.skip('bumpversion') 14 | def test_replace_attribute(python_module): 15 | attributes.replace_attribute('test_app', '__version__', '1.0.0', dry_run=False) 16 | expected_content = list(context.initial_init_content) 17 | expected_content[2] = "__version__ = '1.0.0'" 18 | assert '\n'.join(expected_content) == ''.join(open(context.tmp_file).readlines()) 19 | 20 | 21 | @pytest.mark.skip('bumpversion') 22 | def test_replace_attribute_dry_run(python_module): 23 | attributes.replace_attribute('test_app', '__version__', '1.0.0', dry_run=True) 24 | expected_content = list(context.initial_init_content) 25 | assert '\n'.join(expected_content) == ''.join(open(context.tmp_file).readlines()) 26 | 27 | 28 | @pytest.mark.skip('bumpversion') 29 | def test_has_attribute(python_module): 30 | assert attributes.has_attribute(context.module_name, '__version__') 31 | -------------------------------------------------------------------------------- /tests/test_changelog.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from changes import changelog 4 | 5 | from . import context 6 | 7 | 8 | def test_write_new_changelog(python_module): 9 | content = ['This is the heading\n\n', 'This is the first line\n'] 10 | 11 | with open(context.tmp_file, 'w') as existing_file: 12 | existing_file.writelines(content) 13 | 14 | changelog.write_new_changelog('test_app', context.tmp_file, 'Now this is') 15 | 16 | assert ''.join(content) == ''.join(open(context.tmp_file).readlines()) 17 | 18 | with open(context.tmp_file, 'w') as existing_file: 19 | existing_file.writelines(content) 20 | 21 | changelog.write_new_changelog( 22 | 'https://github.com/someuser/test_app', 23 | context.tmp_file, 24 | 'Now this is', 25 | dry_run=False, 26 | ) 27 | expected_content = [ 28 | '# [Changelog](https://github.com/someuser/test_app/releases)\n', 29 | 'Now this is\n', 30 | 'This is the first line\n', 31 | ] 32 | 33 | assert ''.join(expected_content) == ''.join(open(context.tmp_file).readlines()) 34 | 35 | 36 | def test_replace_sha_with_commit_link(): 37 | repo_url = 'http://github.com/michaeljoseph/changes' 38 | log = 'dde9538 Coverage for all python version runs' 39 | expected_content = [ 40 | '[dde9538](http://github.com/michaeljoseph/changes/commit/dde9538) Coverage for all python version runs' 41 | ] 42 | assert expected_content == changelog.replace_sha_with_commit_link(repo_url, log) 43 | 44 | 45 | @pytest.mark.skip('Towncrier') 46 | def test_generate_changelog(): 47 | changelog.generate_changelog(context) 48 | assert isinstance(context.changelog_content, list) 49 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | 3 | import changes 4 | from changes.cli import main 5 | 6 | 7 | def test_version(): 8 | runner = CliRunner() 9 | result = runner.invoke(main, ['--version']) 10 | assert result.exit_code == 0 11 | assert result.output == 'changes {}\n'.format(changes.__version__) 12 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # from os.path import exists 2 | # 3 | # import click 4 | # from click.testing import CliRunner 5 | # from plumbum.cmd import git 6 | # 7 | # from changes import config 8 | # from changes.config import Config 9 | 10 | 11 | # def test_no_config(): 12 | # with CliRunner().isolated_filesystem(): 13 | # assert not exists('.changes.toml') 14 | # assert config.project_config() == config.DEFAULTS 15 | # assert exists('.changes.toml') 16 | # 17 | # 18 | # def test_existing_config(): 19 | # with CliRunner().isolated_filesystem(): 20 | # with click.open_file('.changes.toml', 'w') as f: 21 | # f.write( 22 | # '[tool.changes]\n' 23 | # 'project_name = "foo"\n' 24 | # ) 25 | # 26 | # expected_config = {'tool': {'changes': {'project_name': 'foo'}}} 27 | # assert expected_config == config.project_config() 28 | # 29 | # 30 | # def test_malformed_config_returns_dict(): 31 | # 32 | # with CliRunner().isolated_filesystem(): 33 | # with click.open_file('.changes.toml', 'w') as f: 34 | # f.write('something\n\n-another thing\n') 35 | # assert config.project_config() == {} 36 | # 37 | # 38 | # def test_store_settings(): 39 | # with CliRunner().isolated_filesystem(): 40 | # config.store_settings({'foo':'bar'}) 41 | # assert exists('.changes.toml') 42 | # assert config.project_config() == {'foo':'bar'} 43 | # 44 | # 45 | # def test_parsed_repo_url(): 46 | # with CliRunner().isolated_filesystem(): 47 | # git('init') 48 | # git('remote', 'add', 'origin', 'https://github.com/michaeljoseph/test_app.git') 49 | # 50 | # context = Config( 51 | # 'something', True, True, True, 52 | # 'requirements.txt', '0.0.2', '0.0.1', 53 | # 'https://github.com/someuser/test_app', None 54 | # ) 55 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | from pathlib import Path 4 | 5 | import pytest 6 | import responses 7 | 8 | import changes 9 | 10 | from .conftest import AUTH_TOKEN_ENVVAR, BUG_LABEL_JSON, LABEL_URL 11 | 12 | 13 | @pytest.fixture 14 | def answer_prompts(mocker): 15 | mocker.patch('changes.config.click.launch', autospec=True) 16 | 17 | prompt = mocker.patch('changes.config.click.prompt', autospec=True) 18 | prompt.side_effect = ['foo', 'docs/releases', 'version.txt', '.'] 19 | 20 | prompt = mocker.patch('changes.config.prompt.choose_labels', autospec=True) 21 | prompt.return_value = ['bug'] 22 | 23 | saved_token = None 24 | if os.environ.get(AUTH_TOKEN_ENVVAR): 25 | saved_token = os.environ[AUTH_TOKEN_ENVVAR] 26 | del os.environ[AUTH_TOKEN_ENVVAR] 27 | 28 | yield 29 | 30 | if saved_token: 31 | os.environ[AUTH_TOKEN_ENVVAR] = saved_token 32 | 33 | 34 | @responses.activate 35 | def test_init_prompts_for_auth_token_and_writes_tool_config( 36 | capsys, git_repo, changes_config_in_tmpdir, answer_prompts 37 | ): 38 | responses.add( 39 | responses.GET, 40 | LABEL_URL, 41 | json=BUG_LABEL_JSON, 42 | status=200, 43 | content_type='application/json', 44 | ) 45 | 46 | changes.initialise() 47 | 48 | assert changes_config_in_tmpdir.exists() 49 | expected_config = textwrap.dedent( 50 | """\ 51 | [changes] 52 | auth_token = "foo" 53 | """ 54 | ) 55 | assert expected_config == changes_config_in_tmpdir.read_text() 56 | 57 | expected_output = textwrap.dedent( 58 | """\ 59 | No auth token found, asking for it... 60 | You need a Github Auth Token for changes to create a release. 61 | Releases directory {} not found, creating it.... 62 | """.format( 63 | Path('docs').joinpath('releases') 64 | ) 65 | ) 66 | out, _ = capsys.readouterr() 67 | assert expected_output == out 68 | 69 | 70 | @responses.activate 71 | def test_init_finds_auth_token_in_environment( 72 | capsys, 73 | git_repo, 74 | with_auth_token_envvar, 75 | changes_config_in_tmpdir, 76 | with_releases_directory_and_bumpversion_file_prompt, 77 | ): 78 | responses.add( 79 | responses.GET, 80 | LABEL_URL, 81 | json=BUG_LABEL_JSON, 82 | status=200, 83 | content_type='application/json', 84 | ) 85 | 86 | changes.initialise() 87 | 88 | # envvar setting is not written to the config file 89 | assert not changes_config_in_tmpdir.exists() 90 | 91 | expected_output = textwrap.dedent( 92 | """\ 93 | Found Github Auth Token in the environment... 94 | Releases directory {} not found, creating it.... 95 | """.format( 96 | Path('docs').joinpath('releases') 97 | ) 98 | ) 99 | out, _ = capsys.readouterr() 100 | assert expected_output == out 101 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | 3 | from changes import packaging 4 | 5 | from . import context 6 | 7 | 8 | def test_build_distributions(): 9 | with CliRunner().isolated_filesystem(): 10 | packaging.build_distributions(context) 11 | 12 | 13 | def test_install_package(): 14 | with CliRunner().isolated_filesystem(): 15 | packaging.install_package(context) 16 | 17 | 18 | def test_upload_package(): 19 | with CliRunner().isolated_filesystem(): 20 | packaging.upload_package(context) 21 | 22 | 23 | def test_install_from_pypi(): 24 | with CliRunner().isolated_filesystem(): 25 | packaging.install_from_pypi(context) 26 | -------------------------------------------------------------------------------- /tests/test_probe.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from click.testing import CliRunner 5 | 6 | from changes import exceptions, probe 7 | 8 | 9 | def test_probe_project(python_module): 10 | assert probe.probe_project('test_app') 11 | 12 | 13 | def test_has_binary(): 14 | assert probe.has_binary('git') 15 | 16 | 17 | def test_has_no_binary(): 18 | assert not probe.has_binary('foo') 19 | 20 | 21 | def test_has_test_runner(): 22 | assert probe.has_test_runner() 23 | 24 | 25 | def test_accepts_readme(python_module): 26 | for ext in probe.README_EXTENSIONS: 27 | path = 'README{0}'.format(ext) 28 | open(path, 'w') 29 | assert probe.has_readme() 30 | os.remove(path) 31 | 32 | 33 | def test_refuses_readme(): 34 | with CliRunner().isolated_filesystem(): 35 | for ext in ['.py', '.doc', '.mp3']: 36 | path = 'README{0}'.format(ext) 37 | open(path, 'w') 38 | with pytest.raises(exceptions.ProbeException): 39 | probe.has_readme() 40 | os.remove(path) 41 | 42 | 43 | def test_fails_for_missing_readme(python_module): 44 | with CliRunner().isolated_filesystem(): 45 | with pytest.raises(exceptions.ProbeException): 46 | probe.has_readme() 47 | -------------------------------------------------------------------------------- /tests/test_publish.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import textwrap 3 | from datetime import date 4 | from pathlib import Path 5 | 6 | import pytest 7 | import responses 8 | from plumbum.cmd import git 9 | 10 | import changes 11 | from changes.commands import publish, stage 12 | 13 | from .conftest import ( 14 | BUG_LABEL_JSON, 15 | ISSUE_URL, 16 | LABEL_URL, 17 | PULL_REQUEST_JSON, 18 | RELEASES_URL, 19 | github_merge_commit, 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def answer_prompts(mocker): 25 | prompt = mocker.patch('changes.commands.publish.click.confirm', autospec=True) 26 | prompt.side_effect = ['y'] 27 | 28 | 29 | def test_publish_no_staged_release(capsys, configured): 30 | changes.initialise() 31 | publish.publish() 32 | 33 | expected_output = textwrap.dedent( 34 | """\ 35 | No staged release to publish... 36 | """ 37 | ) 38 | out, _ = capsys.readouterr() 39 | assert expected_output == out 40 | 41 | 42 | @responses.activate 43 | def test_publish(capsys, configured, answer_prompts): 44 | 45 | github_merge_commit(111) 46 | responses.add( 47 | responses.GET, 48 | ISSUE_URL, 49 | json=PULL_REQUEST_JSON, 50 | status=200, 51 | content_type='application/json', 52 | ) 53 | responses.add( 54 | responses.GET, 55 | LABEL_URL, 56 | json=BUG_LABEL_JSON, 57 | status=200, 58 | content_type='application/json', 59 | ) 60 | responses.add( 61 | responses.POST, 62 | RELEASES_URL, 63 | json={'upload_url': 'foo'}, 64 | status=200, 65 | content_type='application/json', 66 | ) 67 | 68 | changes.initialise() 69 | stage.stage( 70 | draft=False, release_name='Icarus', release_description='The first flight' 71 | ) 72 | 73 | release_notes_path = Path( 74 | 'docs/releases/0.0.2-{}-Icarus.md'.format(date.today().isoformat()) 75 | ) 76 | assert release_notes_path.exists() 77 | 78 | publish.publish() 79 | 80 | pre = textwrap.dedent( 81 | """\ 82 | Staging [fix] release for version 0.0.2... 83 | Running: bumpversion --verbose --allow-dirty --no-commit --no-tag patch... 84 | Generating Release... 85 | Writing release notes to {release_notes_path}... 86 | Publishing release 0.0.2... 87 | Running: git add version.txt .bumpversion.cfg {release_notes_path}... 88 | Running: git commit --message="# 0.0.2 ({release_date}) Icarus 89 | """.format( 90 | release_notes_path=release_notes_path, release_date=date.today().isoformat() 91 | ) 92 | ).splitlines() 93 | 94 | expected_release_notes_content = [ 95 | 'The first flight', 96 | '## Bug', 97 | '* #111 The title of the pull request', 98 | ] 99 | 100 | post = textwrap.dedent( 101 | """\ 102 | "... 103 | Running: git tag 0.0.2... 104 | Running: git push --tags... 105 | Creating GitHub Release... 106 | Published release 0.0.2... 107 | """ 108 | ).splitlines() 109 | 110 | out, _ = capsys.readouterr() 111 | 112 | assert pre + expected_release_notes_content + post == out.splitlines() 113 | 114 | last_commit = git(shlex.split('show --name-only')) 115 | expected_files = ['version.txt', '.bumpversion.cfg', release_notes_path] 116 | assert [ 117 | expected_file 118 | for expected_file in expected_files 119 | if str(expected_file) in last_commit 120 | ] 121 | 122 | assert '0.0.2' in git(shlex.split('tag --list')) 123 | 124 | assert release_notes_path.exists() 125 | expected_release_notes = [ 126 | '# 0.0.2 ({}) Icarus'.format(date.today().isoformat()), 127 | 'The first flight', 128 | '## Bug', 129 | '* #111 The title of the pull request', 130 | ] 131 | assert expected_release_notes == release_notes_path.read_text().splitlines() 132 | -------------------------------------------------------------------------------- /tests/test_repository.py: -------------------------------------------------------------------------------- 1 | from plumbum.cmd import git 2 | from semantic_version import Version 3 | 4 | from changes.models.repository import GitRepository 5 | 6 | 7 | def test_repository_parses_remote_url(git_repo): 8 | repository = GitRepository() 9 | assert 'test_app' == repository.repo 10 | assert 'michaeljoseph' == repository.owner 11 | assert repository.is_github 12 | assert repository.platform == 'github' 13 | 14 | 15 | def test_repository_parses_versions(git_repo): 16 | repository = GitRepository() 17 | 18 | v1 = Version('0.0.1') 19 | 20 | assert [v1] == repository.versions 21 | 22 | assert v1 == repository.latest_version 23 | 24 | 25 | # FIXME 26 | # def test_latest_version_unreleased(git_repo): 27 | # repository = models.GitRepository() 28 | # 29 | # assert 0 == len(repository.versions) 30 | # 31 | # assert models.GitRepository.VERSION_ZERO == repository.latest_version 32 | 33 | 34 | def test_latest_version(git_repo): 35 | git('tag', '0.0.2') 36 | git('tag', '0.0.3') 37 | 38 | repository = GitRepository() 39 | 40 | expected_versions = [Version('0.0.1'), Version('0.0.2'), Version('0.0.3')] 41 | assert expected_versions == repository.versions 42 | 43 | assert Version('0.0.3') == repository.latest_version 44 | -------------------------------------------------------------------------------- /tests/test_shell.py: -------------------------------------------------------------------------------- 1 | from changes import shell 2 | 3 | 4 | def test_handle_dry_run(git_repo): 5 | assert '' == shell.dry_run('diff README.md README.md', False) 6 | 7 | 8 | def test_handle_dry_run_true(git_repo): 9 | assert shell.dry_run('diff README.md README.md', True) 10 | -------------------------------------------------------------------------------- /tests/test_stage.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import textwrap 3 | from datetime import date 4 | from pathlib import Path 5 | 6 | import responses 7 | from plumbum.cmd import git 8 | 9 | import changes 10 | from changes.commands import stage 11 | 12 | from .conftest import ( 13 | BUG_LABEL_JSON, 14 | ISSUE_URL, 15 | LABEL_URL, 16 | PULL_REQUEST_JSON, 17 | github_merge_commit, 18 | ) 19 | 20 | 21 | @responses.activate 22 | def test_stage_draft(capsys, configured): 23 | 24 | github_merge_commit(111) 25 | 26 | responses.add( 27 | responses.GET, 28 | ISSUE_URL, 29 | json=PULL_REQUEST_JSON, 30 | status=200, 31 | content_type='application/json', 32 | ) 33 | responses.add( 34 | responses.GET, 35 | LABEL_URL, 36 | json=BUG_LABEL_JSON, 37 | status=200, 38 | content_type='application/json', 39 | ) 40 | 41 | changes.initialise() 42 | stage.stage(draft=True) 43 | 44 | release_notes_path = Path( 45 | 'docs/releases/0.0.2-{}.md'.format(date.today().isoformat()) 46 | ) 47 | expected_output = textwrap.dedent( 48 | """\ 49 | Staging [fix] release for version 0.0.2... 50 | Running: bumpversion --dry-run --verbose --no-commit --no-tag --allow-dirty patch... 51 | Generating Release... 52 | Would have created {}:... 53 | """.format( 54 | release_notes_path 55 | ) 56 | ) 57 | 58 | expected_release_notes_content = [ 59 | '# 0.0.2 ({})'.format(date.today().isoformat()), 60 | '', 61 | '## Bug', 62 | '* #111 The title of the pull request', 63 | '...', 64 | ] 65 | 66 | out, _ = capsys.readouterr() 67 | 68 | assert ( 69 | expected_output.splitlines() + expected_release_notes_content 70 | == out.splitlines() 71 | ) 72 | 73 | assert not release_notes_path.exists() 74 | 75 | 76 | @responses.activate 77 | def test_stage(capsys, configured): 78 | responses.add( 79 | responses.GET, 80 | LABEL_URL, 81 | json=BUG_LABEL_JSON, 82 | status=200, 83 | content_type='application/json', 84 | ) 85 | 86 | github_merge_commit(111) 87 | responses.add( 88 | responses.GET, 89 | ISSUE_URL, 90 | json=PULL_REQUEST_JSON, 91 | status=200, 92 | content_type='application/json', 93 | ) 94 | 95 | changes.initialise() 96 | stage.stage( 97 | draft=False, release_name='Icarus', release_description='The first flight' 98 | ) 99 | 100 | release_notes_path = Path( 101 | 'docs/releases/0.0.2-{}-Icarus.md'.format(date.today().isoformat()) 102 | ) 103 | expected_output = textwrap.dedent( 104 | """\ 105 | Staging [fix] release for version 0.0.2... 106 | Running: bumpversion --verbose --allow-dirty --no-commit --no-tag patch... 107 | Generating Release... 108 | Writing release notes to {}... 109 | """.format( 110 | release_notes_path 111 | ) 112 | ) 113 | out, _ = capsys.readouterr() 114 | assert expected_output == out 115 | 116 | assert release_notes_path.exists() 117 | expected_release_notes = [ 118 | '# 0.0.2 ({}) Icarus'.format(date.today().isoformat()), 119 | 'The first flight', 120 | '## Bug', 121 | '* #111 The title of the pull request', 122 | ] 123 | assert expected_release_notes == release_notes_path.read_text().splitlines() 124 | 125 | # changelog_path = Path('CHANGELOG.md') 126 | # expected_changelog = [ 127 | # '# Changelog', 128 | # '', 129 | # '', 130 | # # FIXME: 131 | # '# Changelog# 0.0.2 ({}) Icarus'.format(date.today().isoformat()), 132 | # 'The first flight', 133 | # '## Bug', 134 | # ' ', 135 | # '* #111 The title of the pull request', 136 | # ' ', 137 | # ] 138 | # assert expected_changelog == changelog_path.read_text().splitlines() 139 | 140 | 141 | @responses.activate 142 | def test_stage_discard(capsys, configured): 143 | responses.add( 144 | responses.GET, 145 | LABEL_URL, 146 | json=BUG_LABEL_JSON, 147 | status=200, 148 | content_type='application/json', 149 | ) 150 | 151 | github_merge_commit(111) 152 | responses.add( 153 | responses.GET, 154 | ISSUE_URL, 155 | json=PULL_REQUEST_JSON, 156 | status=200, 157 | content_type='application/json', 158 | ) 159 | 160 | changes.initialise() 161 | stage.stage( 162 | draft=False, release_name='Icarus', release_description='The first flight' 163 | ) 164 | 165 | release_notes_path = Path( 166 | 'docs/releases/0.0.2-{}-Icarus.md'.format(date.today().isoformat()) 167 | ) 168 | assert release_notes_path.exists() 169 | 170 | result = git(shlex.split('-c color.status=false status --short --branch')) 171 | 172 | modified_files = [ 173 | '## master', 174 | ' M .bumpversion.cfg', 175 | # ' M CHANGELOG.md', 176 | ' M version.txt', 177 | '?? docs/', 178 | '', 179 | ] 180 | assert '\n'.join(modified_files) == result 181 | 182 | stage.discard(release_name='Icarus', release_description='The first flight') 183 | 184 | expected_output = textwrap.dedent( 185 | """\ 186 | Staging [fix] release for version 0.0.2... 187 | Running: bumpversion --verbose --allow-dirty --no-commit --no-tag patch... 188 | Generating Release... 189 | Writing release notes to {release_notes_path}... 190 | Discarding currently staged release 0.0.2... 191 | Running: git checkout -- version.txt .bumpversion.cfg... 192 | Running: rm {release_notes_path}... 193 | """.format( 194 | release_notes_path=release_notes_path 195 | ) 196 | ) 197 | out, _ = capsys.readouterr() 198 | assert expected_output == out 199 | 200 | result = git(shlex.split('-c color.status=false status --short --branch')) 201 | 202 | modified_files = ['## master', ''] 203 | assert '\n'.join(modified_files) == result 204 | 205 | 206 | @responses.activate 207 | def test_stage_discard_nothing_staged(capsys, configured): 208 | 209 | changes.initialise() 210 | 211 | stage.discard(release_name='Icarus', release_description='The first flight') 212 | 213 | expected_output = textwrap.dedent( 214 | """\ 215 | No staged release to discard... 216 | """ 217 | ) 218 | out, _ = capsys.readouterr() 219 | assert expected_output == out 220 | -------------------------------------------------------------------------------- /tests/test_status.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import responses 4 | 5 | import changes 6 | from changes.commands import status 7 | 8 | from .conftest import ( 9 | BUG_LABEL_JSON, 10 | ISSUE_URL, 11 | LABEL_URL, 12 | PULL_REQUEST_JSON, 13 | github_merge_commit, 14 | ) 15 | 16 | 17 | @responses.activate 18 | def test_status(capsys, configured): 19 | 20 | responses.add( 21 | responses.GET, 22 | LABEL_URL, 23 | json=BUG_LABEL_JSON, 24 | status=200, 25 | content_type='application/json', 26 | ) 27 | 28 | changes.initialise() 29 | status.status() 30 | 31 | expected_output = textwrap.dedent( 32 | """\ 33 | Status [michaeljoseph/test_app]... 34 | Repository: michaeljoseph/test_app... 35 | Latest Version... 36 | 0.0.1 37 | Changes... 38 | 0 changes found since 0.0.1 39 | """ 40 | ) 41 | out, _ = capsys.readouterr() 42 | assert expected_output == out 43 | 44 | # TODO:check project config 45 | 46 | 47 | @responses.activate 48 | def test_status_with_changes(capsys, configured): 49 | 50 | responses.add( 51 | responses.GET, 52 | LABEL_URL, 53 | json=BUG_LABEL_JSON, 54 | status=200, 55 | content_type='application/json', 56 | ) 57 | 58 | github_merge_commit(111) 59 | responses.add( 60 | responses.GET, 61 | ISSUE_URL, 62 | json=PULL_REQUEST_JSON, 63 | status=200, 64 | content_type='application/json', 65 | ) 66 | 67 | changes.initialise() 68 | status.status() 69 | 70 | expected_output = textwrap.dedent( 71 | """\ 72 | Status [michaeljoseph/test_app]... 73 | Repository: michaeljoseph/test_app... 74 | Latest Version... 75 | 0.0.1 76 | Changes... 77 | 1 changes found since 0.0.1 78 | #111 The title of the pull request by @michaeljoseph [bug] 79 | Computed release type fix from changes issue tags... 80 | Proposed version bump 0.0.1 => 0.0.2... 81 | """ 82 | ) 83 | out, _ = capsys.readouterr() 84 | assert expected_output == out 85 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from changes import util 2 | 3 | 4 | def test_extract(): 5 | assert {'a': 1, 'b': 2} == util.extract({'a': 1, 'b': 2, 'c': 3}, ['a', 'b']) 6 | 7 | 8 | def test_extract_arguments(): 9 | assert {'major': True, 'minor': False, 'patch': False} == util.extract_arguments( 10 | {'--major': True, '--minor': False, '--patch': False}, 11 | ['--major', '--minor', '--patch'], 12 | ) 13 | -------------------------------------------------------------------------------- /tests/test_vcs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | from click.testing import CliRunner 6 | from plumbum.cmd import git 7 | 8 | from changes import packaging, vcs 9 | 10 | from . import context 11 | 12 | 13 | def test_commit_version_change(mocker): 14 | with CliRunner().isolated_filesystem(): 15 | dry_run = mocker.patch('changes.shell.dry_run') 16 | vcs.commit_version_change(context) 17 | dry_run.assert_has_calls( 18 | [ 19 | mocker.call( 20 | 'git commit --message="0.0.2" test_app/__init__.py CHANGELOG.md', 21 | True, 22 | ), 23 | mocker.call('git push', True), 24 | ] 25 | ) 26 | 27 | 28 | def test_tag_and_push(mocker): 29 | with CliRunner().isolated_filesystem(): 30 | dry_run = mocker.patch('changes.shell.dry_run') 31 | probe = mocker.patch('changes.probe.has_signing_key') 32 | probe.return_value = False 33 | 34 | vcs.tag_and_push(context) 35 | dry_run.assert_has_calls( 36 | [ 37 | mocker.call('git tag --annotate 0.0.2 --message="0.0.2"', True), 38 | mocker.call('git push --tags', True), 39 | ] 40 | ) 41 | 42 | 43 | @pytest.mark.skip('requires changelog') 44 | @responses.activate 45 | def test_github_release(): 46 | with CliRunner().isolated_filesystem(): 47 | git('init') 48 | git('remote', 'add', 'origin', 'https://github.com/michaeljoseph/test_app.git') 49 | 50 | responses.add( 51 | responses.POST, 52 | 'https://api.github.com/repos/michaeljoseph/test_app/releases', 53 | body=json.dumps(dict(id='release-id', upload_url='http://upload.url.com/')), 54 | status=201, 55 | content_type='application/json', 56 | ) 57 | upload_url = vcs.create_github_release(context, 'gh-token', 'Description') 58 | assert upload_url == 'http://upload.url.com/' 59 | 60 | 61 | @pytest.mark.skip('requires changelog') 62 | @responses.activate 63 | def test_upload_release_distributions(): 64 | context.dry_run = False 65 | distributions = packaging.build_distributions(context) 66 | context.dry_run = True 67 | for _ in distributions: 68 | responses.add( 69 | responses.POST, 70 | 'http://upload.url.com/', 71 | status=201, 72 | content_type='application/json', 73 | ) 74 | vcs.upload_release_distributions( 75 | context, 'gh-token', distributions, 'http://upload.url.com/' 76 | ) 77 | 78 | 79 | @pytest.mark.skip('requires changelog') 80 | def test_signed_tag(mocker): 81 | dry_run = mocker.patch('changes.shell.dry_run') 82 | probe = mocker.patch('changes.probe.has_signing_key') 83 | probe.return_value = True 84 | 85 | vcs.tag_and_push(context) 86 | dry_run.assert_has_calls( 87 | ['git tag --sign 0.0.2 --message="0.0.2"', 'git push --tags'] 88 | ) 89 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from changes import version 4 | 5 | from .conftest import PYTHON_MODULE 6 | 7 | 8 | @pytest.mark.skip('bumpversion') 9 | def test_increment(): 10 | assert '1.0.0' == version.increment('0.0.1', major=True) 11 | 12 | assert '0.1.0' == version.increment('0.0.1', minor=True) 13 | 14 | assert '1.0.1' == version.increment('1.0.0', patch=True) 15 | 16 | 17 | @pytest.mark.skip('bumpversion') 18 | def test_current_version(python_module): 19 | assert '0.0.1' == version.current_version(PYTHON_MODULE) 20 | 21 | 22 | @pytest.mark.skip('bumpversion') 23 | def test_get_new_version(mocker): 24 | with mocker.patch('builtins.input') as mock_raw_input: 25 | mock_raw_input.return_value = None 26 | assert '0.1.0' == version.get_new_version( 27 | PYTHON_MODULE, '0.0.1', True, minor=True 28 | ) 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint, test, docs, package 3 | isolated_build = True 4 | skipsdist = True 5 | skip_missing_interpreters = true 6 | requires = pip > 21.1 7 | 8 | [gh-actions] 9 | python = 10 | 3.7: py37 11 | 3.8: py38 12 | 3.9: py39 13 | 14 | [gh-actions:env] 15 | PLATFORM = 16 | ubuntu-latest: linux 17 | macos-latest: macos 18 | windows-latest: windows 19 | 20 | [pytest] 21 | junit_family = xunit2 22 | testpaths = tests 23 | addopts = -vv --junitxml=test-reports/junit.xml --cov-report=xml --cov-report=html --cov-report=term-missing --cov changes --html=test-reports/test-report.html --self-contained-html --md docs/tests.md 24 | 25 | [coverage:paths] 26 | source = changes 27 | [coverage:html] 28 | directory = test-reports/coverage_html 29 | [coverage:xml] 30 | output = test-reports/cobertura.xml 31 | [coverage:report] 32 | fail_under = 60 33 | show_missing = True 34 | sort = Cover 35 | [coverage:run] 36 | parallel = True 37 | 38 | [flake8] 39 | ignore = E501 W503 40 | output-file = flake8.txt 41 | 42 | [isort] 43 | known_third_party = attr,bumpversion,cached_property,click,giturlparse,haikunator,inflection,jinja2,pkg_resources,plumbum,pytest,requests,requests_cache,responses,semantic_version,setuptools,sphinx_bootstrap_theme,testtube,toml,uritemplate 44 | multi_line_output=3 45 | include_trailing_comma=True 46 | force_grid_wrap=0 47 | combine_as_imports=True 48 | line_length=88 49 | 50 | [testenv] 51 | description = pytests 52 | deps = poetry 53 | commands_pre = poetry install 54 | commands = pytest {posargs} 55 | 56 | [testenv:lint] 57 | description = pre-commit with black, flake8, isort 58 | deps = pre-commit 59 | commands_pre = 60 | commands = pre-commit run --all {posargs} 61 | 62 | [testenv:docs] 63 | description = mkdocs and pdoc3 64 | whitelist_externals = cp 65 | commands_pre = poetry install 66 | commands = 67 | pdoc3 --html --output-dir site/api --force changes 68 | mkdocs {posargs:build} 69 | 70 | [testenv:package] 71 | description = builds source and wheel distributions 72 | commands = poetry build {posargs} 73 | 74 | [testenv:report-coverage] 75 | description = codecov and scrutinizer integration 76 | passenv = TOXENV CI CODECOV_* 77 | commands_pre = 78 | deps = codecov 79 | commands = codecov -e TOXENV -f test-reports/cobertura.xml 80 | --------------------------------------------------------------------------------