├── .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 | [](https://github.com/michaeljoseph/changes/actions/workflows/tests.yml)
4 | [](https://circleci.com/gh/michaeljoseph/changes/tree/master)
5 | [](https://pypi.python.org/pypi/changes)
6 | [](https://pypi.python.org/pypi/changes)
7 | [](https://codecov.io/github/michaeljoseph/changes?branch=master)
8 | [](https://scrutinizer-ci.com/g/michaeljoseph/changes/?branch=master)
9 | [](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 | 
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------