├── .dockerignore ├── .github ├── FUNDING.yml ├── renovate.json5 ├── mergify.yml ├── workflows │ ├── dockerimage.yml │ └── security.yml └── semver.yaml ├── .gitignore ├── requirements.txt ├── docker-compose.yml ├── SECURITY.md ├── Dockerfile ├── LICENSE ├── pyicloud.diff ├── CODE_OF_CONDUCT.md ├── README.md └── backup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | backup 2 | session -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [chrisns] 2 | custom: ['https://www.paypal.me/cns'] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | backup/ 5 | session 6 | .env 7 | photos -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyicloud==1.0.0 2 | tqdm==4.66.1 3 | pytz==2023.3.post1 4 | pypatch==1.0.1 -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>chrisns/.github:renovate" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | backup: 4 | build: . 5 | volumes: 6 | - ./:/app 7 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please contact [chris@cns.me.uk](mailto:chris@cns.me.uk) [pgp/gpg key](https://github.com/chrisns.gpg) 6 | -------------------------------------------------------------------------------- /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge repomanager 3 | conditions: 4 | - author=the-repository-manager[bot] 5 | actions: 6 | merge: 7 | method: rebase 8 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | uses: chrisns/.github/.github/workflows/dockerbuild.yml@main 7 | with: 8 | platforms: linux/arm64,linux/amd64 9 | secrets: 10 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 11 | -------------------------------------------------------------------------------- /.github/semver.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | force: 3 | major: 1 4 | existing: true 5 | wording: 6 | patch: 7 | - bump 8 | - update 9 | - initial 10 | - tweak 11 | minor: 12 | - change 13 | - improve 14 | - implement 15 | - fix 16 | major: 17 | - breaking 18 | release: 19 | - release-candidate 20 | - add-rc 21 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: "Security Scanning" 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | scan: 9 | name: Security Scan 10 | uses: chrisns/.github/.github/workflows/security-scan.yml@main 11 | permissions: 12 | security-events: write 13 | statuses: write 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.5-slim@sha256:edaf703dce209d774af3ff768fc92b1e3b60261e7602126276f9ceb0e3a96874 as build 2 | 3 | WORKDIR /app 4 | COPY requirements.txt pyicloud.diff ./ 5 | RUN pip install -r requirements.txt && \ 6 | pypatch apply pyicloud.diff pyicloud 7 | 8 | COPY backup.py ./ 9 | 10 | ENV LC_ALL=C.UTF-8 LANG=C.UTF-8 11 | RUN useradd \ 12 | --no-user-group \ 13 | --uid=1000 \ 14 | app 15 | USER 1000 16 | 17 | VOLUME /home/app/photos 18 | VOLUME /home/app/session 19 | VOLUME /home/app/.local/share/python_keyring 20 | 21 | 22 | ENTRYPOINT [ "python", "/app/backup.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chris Nesbitt-Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyicloud.diff: -------------------------------------------------------------------------------- 1 | diff --git a/services/photos.py b/services/photos.py 2 | index 06b3dd3..c8a8c9c 100644 3 | --- a/services/photos.py 4 | +++ b/services/photos.py 5 | @@ -134,21 +134,21 @@ def __init__(self, service_root, session, params): 6 | 7 | self.params.update({"remapEnums": True, "getCurrentSyncToken": True}) 8 | 9 | - url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}" 10 | - json_data = ( 11 | - '{"query":{"recordType":"CheckIndexingState"},' 12 | - '"zoneID":{"zoneName":"PrimarySync"}}' 13 | - ) 14 | - request = self.session.post( 15 | - url, data=json_data, headers={"Content-type": "text/plain"} 16 | - ) 17 | - response = request.json() 18 | - indexing_state = response["records"][0]["fields"]["state"]["value"] 19 | - if indexing_state != "FINISHED": 20 | - raise PyiCloudServiceNotActivatedException( 21 | - "iCloud Photo Library not finished indexing. " 22 | - "Please try again in a few minutes." 23 | - ) 24 | +# url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}" 25 | +# json_data = ( 26 | +# '{"query":{"recordType":"CheckIndexingState"},' 27 | +# '"zoneID":{"zoneName":"PrimarySync"}}' 28 | +# ) 29 | +# request = self.session.post( 30 | +# url, data=json_data, headers={"Content-type": "text/plain"} 31 | +# ) 32 | +# response = request.json() 33 | +# indexing_state = response["records"][0]["fields"]["state"]["value"] 34 | +# if indexing_state != "FINISHED": 35 | +# raise PyiCloudServiceNotActivatedException( 36 | +# "iCloud Photo Library not finished indexing. " 37 | +# "Please try again in a few minutes." 38 | +# ) 39 | 40 | # TODO: Does syncToken ever change? # pylint: disable=fixme 41 | # self.params.update({ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at chris@cns.me.uk. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Archived, use [icloud-photos-downloader](https://github.com/icloud-photos-downloader/icloud_photos_downloader) ⚠️ 2 | 3 | 4 | # iCloud Photos backup 5 | [![Security Scanning](https://github.com/chrisns/icloud-photos-backup/actions/workflows/security.yml/badge.svg)](https://github.com/chrisns/icloud-photos-backup/actions/workflows/security.yml) 6 | [![Docker Image CI](https://github.com/chrisns/icloud-photos-backup/actions/workflows/dockerimage.yml/badge.svg)](https://github.com/chrisns/icloud-photos-backup/actions/workflows/dockerimage.yml) 7 | [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 8 | [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 9 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 10 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 11 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 12 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 13 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 14 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 15 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=bugs)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 16 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=chrisns_icloud-photos-backup&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=chrisns_icloud-photos-backup) 17 | [![Known Vulnerabilities](https://snyk.io/test/github/chrisns/icloud-photos-backup/badge.svg)](https://snyk.io/test/github/chrisns/icloud-photos-backup) 18 | 19 | 20 | 21 | > a tool to backup your photos from iCloud 22 | 23 | Like many others I keep all my family photos in Photos and take comfort that Apple handle the storage + backup. 24 | 25 | However something got me worried, _what if_ I got infected with some ransomware that encrypted or destroyed my photos, from an attack point of view, that'd probably be a pretty lucrative attack. 26 | 27 | Or what if the pictures I have of my kids were misclassified by a well intentioned Apple and I lost access to the originals like [this guy](https://news.yahoo.com/dad-took-photos-naked-toddler-142928196.html) did with his google life. 28 | 29 | ## Usage 30 | 31 | ### 32 | ```bash 33 | mkdir -p session keyring photos 34 | ### Get or renew a login session with 2FA 35 | 36 | docker run \ 37 | --rm -ti \ 38 | -v ${PWD}/session:/tmp/pyicloud \ 39 | -v ${PWD}/keyring:/home/app/.local/share/python_keyring \ 40 | -e USERNAME="xxx@mac.com" \ 41 | ghcr.io/chrisns/icloud-photos-backup 42 | 43 | # If it works this should start downloading photos, but they're only going to a docker volume, not to your host machine to ^C to exit 44 | 45 | docker run \ 46 | --name photobackup \ 47 | -d \ 48 | -v ${PWD}/backup:/app/photos \ 49 | -v ${PWD}/keyring:/home/app/.local/share/python_keyring \ 50 | -v ${PWD}/session:/tmp/pyicloud \ 51 | -e USERNAME="xxx@mac.com" \ 52 | ghcr.io/chrisns/icloud-photos-backup 53 | 54 | # you can follow the logs to see progress, initial backup could take a LONG time (days-weeks) 55 | docker logs -f photobackup 56 | 57 | # you can then maybe add a cron job to do: 58 | 59 | docker start -a photobackup 60 | ``` 61 | 62 | ## But I don't trust you [@chrisns](@chrisns) with my credentials 63 | 64 | No, why on earth would you, you'd be mad to blindly run the above docker command, so I'd really urge you to pull this repo, check all the dependencies and use very much at your own discretion. 65 | 66 | My target intention is to personally run this on an isolated Raspberry pi with no remote access, and just enough network to talk to iCloud and also syslog so I can observe errors and manually fix. 67 | -------------------------------------------------------------------------------- /backup.py: -------------------------------------------------------------------------------- 1 | import click 2 | import re 3 | import sys 4 | import socket 5 | import string 6 | import unicodedata 7 | import requests 8 | from datetime import datetime 9 | from time import mktime 10 | import pytz 11 | import os 12 | from pyicloud.services.photos import PhotoAlbum 13 | from pyicloud import PyiCloudService 14 | from pyicloud.utils import store_password_in_keyring 15 | from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudNoStoredPasswordAvailableException 16 | from tqdm import tqdm 17 | 18 | BACKUP_FOLDER = os.path.join(os.getcwd(), 'photos') 19 | 20 | 21 | def clean_filename(filename): 22 | whitelist=valid_filename_chars = "-_.() %s%s" % (string.ascii_letters, string.digits) 23 | 24 | # keep only valid ascii chars 25 | cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode() 26 | 27 | cleaned_filename = ''.join(c for c in cleaned_filename if c in whitelist) 28 | return cleaned_filename[:255] 29 | 30 | def validate_date(ctx, param, value): 31 | if not value: 32 | return value 33 | 34 | if re.match('(1|2)[0-9]{3}-(0|1)[0-9]-[0-3][0-9]', value) == None: 35 | raise click.BadParameter('Invalid date format, should follow YYYY-MM-DD (ex: 1984-11-23)') 36 | return pytz.utc.localize(datetime.strptime(value, '%Y-%m-%d')).date() 37 | 38 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 39 | @click.command(context_settings=CONTEXT_SETTINGS, options_metavar='') 40 | @click.option('--username', 41 | help='Your iCloud username or email address', 42 | metavar='', 43 | envvar='USERNAME', 44 | prompt='iCloud username/email') 45 | @click.option('--from-date', 46 | help='Specify a date YYYY-mm-dd to begin downloading images from, leaving it out will result in downloading all images', 47 | callback=validate_date, 48 | envvar='FROM_DATE', 49 | metavar='') 50 | @click.option('--to-date', 51 | help='Specify a date YYYY-mm-dd to begin downloading images to, leaving it out will result in downloading images up till today', 52 | callback=validate_date, 53 | envvar='TO_DATE', 54 | metavar='') 55 | @click.option('--max-skips', 56 | help='If set, the script will stop early after skipping this many photos because they were already downloaded.', 57 | type=click.IntRange(0), 58 | envvar='MAX_SKIPS', 59 | metavar='') 60 | @click.option('--suffix-id/--prefix-id', 61 | help='Controls if the photo ID will be prefixed (default) or suffixed.', 62 | envvar='SUFFIX_ID', 63 | default=False) 64 | 65 | def backup(username, from_date, to_date, max_skips, suffix_id): 66 | icloud = authenticate(username) 67 | 68 | album = icloud.photos.all 69 | 70 | if max_skips is None: 71 | max_skips = 0 72 | 73 | # sort by asset-date instead of added-date 74 | album.obj_type = "CPLAssetByAssetDate" 75 | album.list_type = "CPLAssetAndMasterByAddedDate" 76 | album.page_size = 100 # seems to be capped at 100. 77 | 78 | print("Album '{0}' contains a total of {1} photos".format(album.title, len(album))) 79 | 80 | # this could be very memory heavy, to store all photos in-memory instead of using a generator. 81 | # to greatly speed up this, we could fork https://github.com/picklepete/pyicloud/blob/master/pyicloud/services/photos.py#L335 to allow us to inject a query-filter to query for photos only within the date range 82 | # we can reduce the queries needed from O(n) -> O(1) 83 | failed_photos = [] 84 | downloaded = 0 85 | 86 | progress_bar = tqdm(total=len(album), desc="Downloading", position=0, bar_format="{l_bar}{bar}|{n_fmt}/{total_fmt}") 87 | 88 | def download_photo(photo): 89 | nonlocal downloaded 90 | nonlocal failed_photos 91 | downloaded += 1 92 | 93 | try: 94 | 95 | if not os.path.exists(photo.download_dir): 96 | os.makedirs(photo.download_dir) 97 | 98 | download_url = photo.download('original') 99 | # size = photo.size() 100 | 101 | if download_url: 102 | download_bar = tqdm(position=1, unit="byte", desc=photo.download_path, unit_scale=True, bar_format="{l_bar}|{rate_fmt}|{n_fmt}|{elapsed}") 103 | with open(photo.download_path, 'wb') as file: 104 | for chunk in download_url.iter_content(chunk_size=1024): 105 | if chunk: 106 | download_bar.update(1024) 107 | file.write(chunk) 108 | file.close() 109 | 110 | # Preserve the creation date 111 | mod_time = mktime(photo.created.timetuple()) 112 | os.utime(photo.download_path, (mod_time, mod_time)) 113 | 114 | download_bar.close() 115 | 116 | 117 | except (requests.exceptions.ConnectionError, socket.timeout): 118 | failed_photos.append(photo) 119 | 120 | # Keep track of the number of photos skipped in a row, so that we can stop early 121 | # if it seems that we've gotten all the new photos. 122 | skipped_in_a_row = 0 123 | 124 | # before we can rely heavy on the photos are sorted DESC by asset_date we can create these guards. 125 | for photo in album.photos: 126 | progress_bar.update() 127 | if to_date is not None and photo.created.date() > to_date: 128 | # skip photos until photos are older than our 'to_date' 129 | continue 130 | 131 | if from_date is not None and photo.created.date() < from_date: 132 | # break out when we begin to receive photos created earlier than our 'from_date' 133 | break 134 | 135 | date_path = '{:%Y-%m}'.format(photo.created) # store files in folders grouped by year + month. 136 | photo.download_dir = os.path.join(BACKUP_FOLDER, username, date_path) 137 | filename = photo.filename.encode('utf-8').decode('ascii', 'ignore') 138 | clean_id = clean_filename(photo.id) 139 | if suffix_id: 140 | # Find first dot in filename 141 | dot_index = filename.find('.') 142 | if dot_index == -1: 143 | # No dot, so just append the ID 144 | filename += '_' + clean_id 145 | else: 146 | # Insert the ID before the dot 147 | filename = filename[:dot_index] + '_' + clean_id + filename[dot_index:] 148 | else: 149 | filename = clean_id + filename 150 | photo.download_path = os.path.join(photo.download_dir, filename) 151 | if os.path.isfile(photo.download_path): 152 | # If the script was terminated early previously, then we might have ended up with 153 | # an incomplete file. By checking the file size, we at least make sure that 154 | # the file is at least the size we expect it to be. 155 | stats = os.stat(photo.download_path) 156 | if stats.st_size >= photo.size: 157 | #skip when we've already fetched the photo 158 | skipped_in_a_row += 1 159 | 160 | # Maximum number of skipped photos reached? 161 | if max_skips > 0 and skipped_in_a_row >= max_skips: 162 | print("Reached maximum number of consecutive skipped photos, stopping early.") 163 | break 164 | # Otherwise, we'll just keep going. 165 | continue 166 | else: 167 | print("Download {0} again, as it seems incomplete based on file size ({1} vs {2})".format(filename, stats.st_size, photo.size)) 168 | os.remove(photo.download_path) 169 | 170 | skipped_in_a_row = 0 171 | download_photo(photo) 172 | 173 | progress_bar.close() 174 | 175 | print("Finished downloaded {0} of {1} photos, with {2} failed".format(downloaded, len(album), len(failed_photos))) 176 | 177 | if failed_photos: 178 | print("-----------------------------------------------") 179 | for photo in failed_photos: 180 | print(" {0}".format(photo.filename)) 181 | 182 | 183 | def authenticate(username): 184 | """attempt to authenticate user using provided credentials""" 185 | try: 186 | api = PyiCloudService(username) 187 | except (PyiCloudNoStoredPasswordAvailableException, PyiCloudFailedLoginException): 188 | import click 189 | password = click.prompt('iCloud Password', hide_input=True) 190 | store_password_in_keyring(username, password) 191 | api = PyiCloudService(username) 192 | 193 | 194 | if api.requires_2fa: 195 | print("Two-factor authentication required.") 196 | code = input("Enter the code you received of one of your approved devices: ") 197 | result = api.validate_2fa_code(code) 198 | print("Code validation result: %s" % result) 199 | 200 | if not result: 201 | print("Failed to verify security code") 202 | sys.exit(1) 203 | 204 | if not api.is_trusted_session: 205 | print("Session is not trusted. Requesting trust...") 206 | result = api.trust_session() 207 | print("Session trust result %s" % result) 208 | 209 | if not result: 210 | print("Failed to request trust. You will likely be prompted for the code again in the coming weeks") 211 | elif api.requires_2sa: 212 | import click 213 | print("Two-step authentication required. Your trusted devices are:") 214 | 215 | devices = api.trusted_devices 216 | for i, device in enumerate(devices): 217 | print(" %s: %s" % (i, device.get('deviceName', 218 | "SMS to %s" % device.get('phoneNumber')))) 219 | 220 | device = click.prompt('Which device would you like to use?', default=0) 221 | device = devices[device] 222 | if not api.send_verification_code(device): 223 | print("Failed to send verification code") 224 | sys.exit(1) 225 | 226 | code = click.prompt('Please enter validation code') 227 | if not api.validate_verification_code(device, code): 228 | print("Failed to verify verification code") 229 | sys.exit(1) 230 | 231 | return api 232 | 233 | 234 | # if __name__ == '__main__': 235 | backup() --------------------------------------------------------------------------------