diff --git a/.github/actions/install-dependencies/action.sh b/.github/actions/install-dependencies/action.sh index 8cfccf9b..2426cb1d 100755 --- a/.github/actions/install-dependencies/action.sh +++ b/.github/actions/install-dependencies/action.sh @@ -11,4 +11,9 @@ fi if [[ "${INSTALL_TEST_REQUIREMENTS}" == "true" ]]; then echo "Installing test requirements" pip install -r requirements-test.txt -fi \ No newline at end of file +fi + +if [[ "${INSTALL_DOCS_REQUIREMENTS}" == "true" ]]; then + echo "Installing docs requirements" + pip install -r requirements-docs.txt +fi diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 2d771846..2e751577 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -9,6 +9,10 @@ inputs: description: "Should requirements-test.txt be installed" default: "false" required: false + docs-requirements: + description: "Should requirements-docs.txt be installed" + default: "false" + required: false runs: using: "composite" steps: @@ -17,4 +21,5 @@ runs: shell: "bash" env: INSTALL_REQUIREMENTS: ${{ inputs.requirements }} - INSTALL_TEST_REQUIREMENTS: ${{ inputs.test-requirements }} \ No newline at end of file + INSTALL_TEST_REQUIREMENTS: ${{ inputs.test-requirements }} + INSTALL_DOCS_REQUIREMENTS: ${{ inputs.docs-requirements }} diff --git a/.github/actions/publish-docs-with-mike/action.yml b/.github/actions/publish-docs-with-mike/action.yml index 8a45e0a8..648b92a6 100644 --- a/.github/actions/publish-docs-with-mike/action.yml +++ b/.github/actions/publish-docs-with-mike/action.yml @@ -34,4 +34,4 @@ runs: USER_EMAIL: ${{ inputs.email }} VERSION_NAME: ${{ inputs.version_name }} NEW_VERSION: ${{ inputs.new_version }} - RELEASE_TAG: ${{ github.event.release.tag_name }} \ No newline at end of file + RELEASE_TAG: ${{ github.event.release.tag_name }} diff --git a/.github/actions/publish-docs-with-mike/configure_git_user.sh b/.github/actions/publish-docs-with-mike/configure_git_user.sh index e4387089..7be0e72f 100755 --- a/.github/actions/publish-docs-with-mike/configure_git_user.sh +++ b/.github/actions/publish-docs-with-mike/configure_git_user.sh @@ -42,4 +42,4 @@ fi echo "::debug::Falling back to GITHUB_ACTOR" LOGIN="${GITHUB_ACTOR:-github_action}" -set_and_exit "${LOGIN}" "${LOGIN}${NO_REPLY_SUFFIX}" \ No newline at end of file +set_and_exit "${LOGIN}" "${LOGIN}${NO_REPLY_SUFFIX}" diff --git a/.github/actions/publish-docs-with-mike/update_docs_for_version.sh b/.github/actions/publish-docs-with-mike/update_docs_for_version.sh index 87338c7f..4f77d9f4 100755 --- a/.github/actions/publish-docs-with-mike/update_docs_for_version.sh +++ b/.github/actions/publish-docs-with-mike/update_docs_for_version.sh @@ -12,4 +12,4 @@ else mike retitle --message "Remove latest from title of ${PREV_LATEST}" "${PREV_LATEST}" "${PREV_LATEST}" fi echo "mike deploy --update-aliases --title \"${NEW_VERSION} (latest)\" \"${NEW_VERSION}\" \"latest\"" -mike deploy --update-aliases --title "${NEW_VERSION} (latest)" "${NEW_VERSION}" "latest" \ No newline at end of file +mike deploy --update-aliases --title "${NEW_VERSION} (latest)" "${NEW_VERSION}" "latest" diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3361f467..c2c65251 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -9,7 +9,7 @@ on: pull_request: {} env: - PYTHON_VERSION: "3.9.14" + PYTHON_VERSION: "3.13.5" jobs: bandit: @@ -68,7 +68,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10" ] + python-version: [ "3.13.5" ] env: TEST_ACCOUNTS_URL: ${{ secrets.TEST_ACCOUNTS_URL }} steps: @@ -113,6 +113,7 @@ jobs: with: requirements: "true" test-requirements: "true" + docs-requirements: "true" - name: Build Docs run: mkdocs build --strict @@ -141,8 +142,9 @@ jobs: with: requirements: "true" test-requirements: "true" + docs-requirements: "true" - - name: Push documentaiton changes + - name: Push documentation changes uses: ./.github/actions/publish-docs-with-mike with: version_name: dev diff --git a/docker/devbox.dockerfile b/docker/devbox.dockerfile index 9a8ecab2..85f10b56 100644 --- a/docker/devbox.dockerfile +++ b/docker/devbox.dockerfile @@ -1,11 +1,10 @@ -FROM python:3.11.4-buster +FROM python:3.13-bookworm ARG _USER="instagrapi" ARG _UID="1001" ARG _GID="100" ARG _SHELL="/bin/bash" - RUN useradd -m -s "${_SHELL}" -N -u "${_UID}" "${_USER}" ENV USER ${_USER} @@ -15,15 +14,13 @@ ENV HOME /home/${_USER} ENV PATH "${HOME}/.local/bin/:${PATH}" ENV PIP_NO_CACHE_DIR "true" - RUN mkdir /app && chown ${UID}:${GID} /app USER ${_USER} COPY --chown=${UID}:${GID} ./requirements* /app/ -COPY --chown=${UID}:${GID} ./util /app/util/ WORKDIR /app -RUN pip install -r requirements.txt -r requirements-test.txt +RUN pip install -r requirements.txt -r requirements-test.txt -r requirements-docs.txt -CMD bash \ No newline at end of file +CMD bash diff --git a/docker/lock_requirements.sh b/docker/lock_requirements.sh index 2eb4c2b6..332e1a0f 100755 --- a/docker/lock_requirements.sh +++ b/docker/lock_requirements.sh @@ -5,4 +5,4 @@ pip install -r requirements.txt printf "# THIS IS AN AUTOGENERATED LOCKFILE. DO NOT EDIT MANUALLY.\n" > requirements.lock pip freeze --disable-pip-version-check --all >> requirements.lock -echo "Rebuild containers to verify there are no conflicts." \ No newline at end of file +echo "Rebuild containers to verify there are no conflicts." diff --git a/docker/run_tests.sh b/docker/run_tests.sh index 3715e621..bab06a14 100755 --- a/docker/run_tests.sh +++ b/docker/run_tests.sh @@ -18,7 +18,7 @@ while [[ $# -gt 0 ]]; do case $arg in --format-code) BLACK_ACTION="--quiet" - ISORT_ACTION="--recursive" + ISORT_ACTION="" ;; -h|--help) usage @@ -36,8 +36,8 @@ done python -m unittest tests.FakeClientTestCase tests.ClientPublicTestCase -echo "Running iSort..." +echo "Running isort..." isort ${ISORT_ACTION} instagrapi echo "Running flake8..." -flake8 instagrapi --count --exit-zero --statistics \ No newline at end of file +flake8 instagrapi --count --exit-zero --statistics diff --git a/instagrapi/config.py b/instagrapi/config.py index 54ecc89c..1d654601 100644 --- a/instagrapi/config.py +++ b/instagrapi/config.py @@ -29,11 +29,39 @@ # QUERY_HASH_COMMENTS = '33ba35852cb50da46f5b5e889df7d159' # QUERY_HASH_TAGGED_MEDIAS = 'be13233562af2d229b008d2976b998b5' -LOGIN_EXPERIMENTS = "ig_android_reg_nux_headers_cleanup_universe,ig_android_device_detection_info_upload,ig_android_nux_add_email_device,ig_android_gmail_oauth_in_reg,ig_android_device_info_foreground_reporting,ig_android_device_verification_fb_signup,ig_android_direct_main_tab_universe_v2,ig_android_passwordless_account_password_creation_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_quickcapture_keep_screen_on,ig_android_device_based_country_verification,ig_android_login_identifier_fuzzy_match,ig_android_reg_modularization_universe,ig_android_security_intent_switchoff,ig_android_device_verification_separate_endpoint,ig_android_suma_landing_page,ig_android_sim_info_upload,ig_android_smartlock_hints_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_retry_create_account_universe,ig_android_caption_typeahead_fix_on_o_universe" +LOGIN_EXPERIMENTS = ( + "ig_android_reg_nux_headers_cleanup_universe," + "ig_android_device_detection_info_upload," + "ig_android_nux_add_email_device," + "ig_android_gmail_oauth_in_reg," + "ig_android_device_info_foreground_reporting," + "ig_android_device_verification_fb_signup," + "ig_android_direct_main_tab_universe_v2," + "ig_android_passwordless_account_password_creation_universe," + "ig_android_direct_add_direct_to_android_native_photo_share_sheet," + "ig_growth_android_profile_pic_prefill_with_fb_pic_2," + "ig_account_identity_logged_out_signals_global_holdout_universe," + "ig_android_quickcapture_keep_screen_on," + "ig_android_device_based_country_verification," + "ig_android_login_identifier_fuzzy_match," + "ig_android_reg_modularization_universe," + "ig_android_security_intent_switchoff," + "ig_android_device_verification_separate_endpoint," + "ig_android_suma_landing_page," + "ig_android_sim_info_upload," + "ig_android_smartlock_hints_universe," + "ig_android_fb_account_linking_sampling_freq_universe," + "ig_android_retry_create_account_universe," + "ig_android_caption_typeahead_fix_on_o_universe" +) SUPPORTED_CAPABILITIES = [ { - "value": "119.0,120.0,121.0,122.0,123.0,124.0,125.0,126.0,127.0,128.0,129.0,130.0,131.0,132.0,133.0,134.0,135.0,136.0,137.0,138.0,139.0,140.0,141.0,142.0", + "value": ( + "119.0,120.0,121.0,122.0,123.0,124.0,125.0,126.0,127.0,128.0," + "129.0,130.0,131.0,132.0,133.0,134.0,135.0,136.0,137.0,138.0," + "139.0,140.0,141.0,142.0" + ), "name": "SUPPORTED_SDK_VERSIONS", }, {"value": "14", "name": "FACE_TRACKER_VERSION"}, diff --git a/instagrapi/extractors.py b/instagrapi/extractors.py index debd5ad4..ce02da86 100644 --- a/instagrapi/extractors.py +++ b/instagrapi/extractors.py @@ -6,6 +6,7 @@ from .types import ( Account, + Broadcast, Collection, Comment, DirectMedia, @@ -29,7 +30,6 @@ StoryMedia, StoryMention, Track, - Broadcast, User, UserShort, Usertag, diff --git a/instagrapi/mixins/account.py b/instagrapi/mixins/account.py index 489201ed..6e94028c 100644 --- a/instagrapi/mixins/account.py +++ b/instagrapi/mixins/account.py @@ -1,8 +1,8 @@ +import json from json.decoder import JSONDecodeError from pathlib import Path from typing import Dict -import json import requests from instagrapi.exceptions import ClientError, ClientLoginRequired @@ -105,7 +105,7 @@ def change_password( return False def remove_bio_links(self, link_ids: list[int]) -> dict: - signed_body={ + signed_body = { "signed_body": "SIGNATURE." + json.dumps( { "_uid": self.user_id, @@ -114,8 +114,7 @@ def remove_bio_links(self, link_ids: list[int]) -> dict: } ) } - return self.private_request('accounts/remove_bio_links/', data = signed_body, with_signature = False) - + return self.private_request('accounts/remove_bio_links/', data=signed_body, with_signature=False) def set_external_url(self, external_url) -> dict: """ diff --git a/instagrapi/mixins/album.py b/instagrapi/mixins/album.py index fd9a492e..8b85c2b7 100644 --- a/instagrapi/mixins/album.py +++ b/instagrapi/mixins/album.py @@ -27,7 +27,8 @@ def album_download(self, media_pk: int, folder: Path = "") -> List[Path]: media_pk: int PK for the album you want to download folder: Path, optional - Directory in which you want to download the album, default is "" and will download the files to working directory. + Directory in which you want to download the album, default is "" + and will download the files to working directory. Returns ------- @@ -62,7 +63,8 @@ def album_download_by_urls(self, urls: List[str], folder: Path = "") -> List[Pat urls: List[str] List of URLs to download media from folder: Path, optional - Directory in which you want to download the album, default is "" and will download the files to working directory. + Directory in which you want to download the album, default is "" + and will download the files to working directory. Returns ------- diff --git a/instagrapi/mixins/auth.py b/instagrapi/mixins/auth.py index b0d6bf33..95315eb6 100644 --- a/instagrapi/mixins/auth.py +++ b/instagrapi/mixins/auth.py @@ -213,7 +213,8 @@ def get_timeline_feed( } data = { "has_camera_permission": "1", - "feed_view_info": "[]", # e.g. [{"media_id":"2634223601739446191_7450075998","version":24,"media_pct":1.0,"time_info":{"10":63124,"25":63124,"50":63124,"75":63124},"latest_timestamp":1628253523186}] + "feed_view_info": "[]", # e.g. [{"media_id":"2634223601739446191_7450075998","version":24, + # "media_pct":1.0,"time_info":{"10":63124,"25":63124,"50":63124,"75":63124},"latest_timestamp":1628253523186}] "phone_id": self.phone_id, "reason": reason, "battery_level": 100, # Random battery level is not simulating real bahaviour @@ -265,7 +266,8 @@ def get_reels_tray_feed( "timezone_offset": str(self.timezone_offset), "tray_session_id": self.tray_session_id, "request_id": self.request_id, - # "latest_preloaded_reel_ids": "[]", # [{"reel_id":"6009504750","media_count":"15","timestamp":1628253494,"media_ids":"[\"2634301737009283814\",\"2634301789371018685\",\"2634301853921370532\",\"2634301920174570551\",\"2634301973895112725\",\"2634302037581608844\",\"2634302088273817272\",\"2634302822117736694\",\"2634303181452199341\",\"2634303245482345741\",\"2634303317473473894\",\"2634303382971517344\",\"2634303441062726263\",\"2634303502039423893\",\"2634303754729475501\"]"},{"reel_id":"4357392188","media_count":"4","timestamp":1628250613,"media_ids":"[\"2634142331579781054\",\"2634142839803515356\",\"2634150786575125861\",\"2634279566740346641\"]"},{"reel_id":"5931631205","media_count":"7","timestamp":1628253023,"media_ids":"[\"2633699694927154768\",\"2634153361241413763\",\"2634196788830183839\",\"2634219197377323622\",\"2634294221109889541\",\"2634299705648894876\",\"2634299760434939842\"]"}], + # "latest_preloaded_reel_ids": "[]", # Long JSON array with reel data + # Example: [{"reel_id":"6009504750","media_count":"15","timestamp":1628253494,"media_ids":"..."}] "page_size": 50, # "_csrftoken": self.token, "_uuid": self.uuid, @@ -280,7 +282,6 @@ def get_reels_tray_feed( class LoginMixin(PreLoginFlowMixin, PostLoginFlowMixin): username = None password = None - authorization = "" # Bearer IGT:2: authorization_data = {} # decoded authorization header last_login = None relogin_attempt = 0 @@ -298,7 +299,8 @@ class LoginMixin(PreLoginFlowMixin, PostLoginFlowMixin): country_code = 1 # Phone code, default USA locale = "en_US" timezone_offset: int = -14400 # New York, GMT-4 in seconds - ig_u_rur = "" # e.g. CLN,49897488153,1666640702:01f7bdb93090f4f773516fc2cf1424178a58a2295b4c754090ba02cb0a834e2d1f731e20 + # Example: CLN,49897488153,1666640702:01f7bdb93090f4f773516fc2cf1424178a58a2295b4c754090ba02cb0a834e2d1f731e20 + ig_u_rur = "" ig_www_claim = "" # e.g. hmac.AR2uidim8es5kYgDiNxY0UG_ZhffFFSt8TGCV5eA1VYYsMNx def __init__(self): @@ -325,7 +327,9 @@ def init(self) -> bool: ) self.set_device(self.settings.get("device_settings")) # c7aeefd59aab78fc0a703ea060ffb631e005e2b3948efb9d73ee6a346c446bf3 - self.bloks_versioning_id = "ce555e5500576acd8e84a66018f54a05720f2dce29f0bb5a1f97f0c10d6fac48" # this param is constant and will change by Instagram app version + self.bloks_versioning_id = ( + "ce555e5500576acd8e84a66018f54a05720f2dce29f0bb5a1f97f0c10d6fac48" + ) # this param is constant and will change by Instagram app version self.set_user_agent(self.settings.get("user_agent")) self.set_uuids(self.settings.get("uuids") or {}) self.set_locale(self.settings.get("locale", self.locale)) @@ -397,11 +401,9 @@ def login( bool A boolean value """ - if username and password: self.username = username self.password = password - if self.username is None or self.password is None: raise BadCredentials("Both username and password must be provided.") @@ -426,8 +428,9 @@ def login( enc_password = self.password_encrypt(self.password) data = { "jazoest": generate_jazoest(self.phone_id), - "country_codes": '[{"country_code":"%d","source":["default"]}]' - % int(self.country_code), + "country_codes": '[{"country_code":"%d","source":["default"]}]' % int( + self.country_code + ), "phone_id": self.phone_id, "enc_password": enc_password, "username": username, @@ -882,7 +885,8 @@ def authorization(self) -> str: return "" def dump_instaman(self): - # helen9151hernandez:AgcXb0GJhAP|Instagram 200.0.0.24.121 Android (24/7.0; 640dpi; 1440x2392; Samsung; SGH-T849; SGH-T849; hi3660; pt_BR; 304101669)|097e7efb59ba976b;03c1746f-77cd-4ac6-8f7e-175b0ba0dc17;c4155719-9d80-466c-b3f7-f98f0a14a372;7fa9e7c7-75e1-498f-8d0f-8f56fe7a4f45|X-MID=YaHXxQABAAFcCc6aAC_OQ53CVDbd;IG-U-DS-USER-ID=50511821576;IG-U-RUR=FRC,50511821576,1669532533:01f705b9f6a7411dc1e985485b1fe39dd317e97b2cf166f380148836d9c2e5233cac5476;Authorization=Bearer IGT:2:eyJkc191c2VyX2lkIjoiNTA1MTE4MjE1NzYiLCJzZXNzaW9uaWQiOiI1MDUxMTgyMTU3NiUzQWtyaEVSbHF2VW8wbnRXJTNBMjQiLCJzaG91bGRfdXNlX2hlYWRlcl9vdmVyX2Nvb2tpZXMiOnRydWV9;X-IG-WWW-Claim=hmac.AR300vJeNkurM8IGnekSoFtSKJmXazjxOawhWNC3d1Gw1OiX;|| + # Example format: helen9151hernandez:AgcXb0GJhAP|Instagram 200.0.0.24.121 Android... + # Long string with user credentials and device info uuids = ";".join( [ self.android_device_id.replace("android-", ""), diff --git a/instagrapi/mixins/media.py b/instagrapi/mixins/media.py index f4fcaa19..eb2c49cc 100644 --- a/instagrapi/mixins/media.py +++ b/instagrapi/mixins/media.py @@ -1154,7 +1154,6 @@ def media_unpin(self, media_pk): """ return self.media_pin(media_pk, True) - def media_create_livestream(self, title="Instagram Live"): """ Create a new live broadcast. diff --git a/instagrapi/mixins/photo.py b/instagrapi/mixins/photo.py index 87455815..c7221435 100644 --- a/instagrapi/mixins/photo.py +++ b/instagrapi/mixins/photo.py @@ -26,8 +26,8 @@ StoryLocation, StoryMedia, StoryMention, - StorySticker, StoryPoll, + StorySticker, Usertag, ) from instagrapi.utils import date_time_original, dumps @@ -699,7 +699,7 @@ def photo_configure_to_story( "count": 0, "font_size": 39.0, "text": o - } + } for o in poll.options ], **poll_extra, diff --git a/instagrapi/mixins/signup.py b/instagrapi/mixins/signup.py index 572e5ca7..6875b88e 100644 --- a/instagrapi/mixins/signup.py +++ b/instagrapi/mixins/signup.py @@ -1,7 +1,5 @@ -import base64 import random import time -from datetime import datetime from uuid import uuid4 from instagrapi.extractors import extract_user_short @@ -127,9 +125,8 @@ def accounts_create( day: int = None, **kwargs, ) -> dict: - timestamp = datetime.now().strftime("%s") - nonce = f'{username}|{timestamp}|\xb9F"\x8c\xa2I\xaaz|\xf6xz\x86\x92\x91Y\xa5\xaa#f*o%\x7f' - sn_nonce = base64.encodebytes(nonce.encode()).decode().strip() + # timestamp = datetime.now().strftime("%s") # Unused variable + # nonce = f'{username}|{timestamp}|\xb9F"\x8c\xa2I\xaaz|\xf6xz\x86\x92\x91Y\xa5\xaa#f*o%\x7f' # Unused variable data = { "is_secondary_account_creation": "true", "jazoest": str(int(random.randint(22300, 22399))), # "22341", @@ -149,7 +146,7 @@ def accounts_create( "one_tap_opt_in": "true", **kwargs, } - return self.private_request("accounts/create/", data, domain= "www.instagram.com") + return self.private_request("accounts/create/", data, domain="www.instagram.com") def challenge_flow(self, data): data = self.challenge_api(data) diff --git a/instagrapi/mixins/video.py b/instagrapi/mixins/video.py index 133f783d..e44c1a86 100644 --- a/instagrapi/mixins/video.py +++ b/instagrapi/mixins/video.py @@ -25,8 +25,8 @@ StoryLocation, StoryMedia, StoryMention, - StorySticker, StoryPoll, + StorySticker, Usertag, ) from instagrapi.utils import date_time_original, dumps @@ -811,7 +811,7 @@ def video_configure_to_story( "count": 0, "font_size": 39.0, "text": o - } + } for o in poll.options ], **poll_extra, diff --git a/instagrapi/story.py b/instagrapi/story.py index 7d69ee1d..fba0bfde 100644 --- a/instagrapi/story.py +++ b/instagrapi/story.py @@ -9,7 +9,12 @@ from moviepy import CompositeVideoClip, ImageClip, TextClip, VideoFileClip except ImportError: try: - from moviepy.editor import CompositeVideoClip, ImageClip, TextClip, VideoFileClip + from moviepy.editor import ( + CompositeVideoClip, + ImageClip, + TextClip, + VideoFileClip, + ) except ImportError: raise Exception("Please install moviepy>=1.0.3 and retry") diff --git a/instagrapi/types.py b/instagrapi/types.py index c56ff54b..34f14076 100644 --- a/instagrapi/types.py +++ b/instagrapi/types.py @@ -10,22 +10,26 @@ field_validator, ) + class TypesBaseModel(BaseModel): model_config = ConfigDict( coerce_numbers_to_str=True ) # (jarrodnorwell) fixed city_id issue + def validate_external_url(cls, v): if v is None or (v.startswith("http") and "://" in v) or isinstance(v, str): return v raise ValidationError("external_url must be a URL or string") # Corrected 'been' to 'be' + class Resource(TypesBaseModel): pk: str video_url: Optional[HttpUrl] = None # for Video and IGTV thumbnail_url: HttpUrl media_type: int + class BioLink(TypesBaseModel): link_id: str url: str @@ -35,6 +39,7 @@ class BioLink(TypesBaseModel): is_pinned: Optional[bool] = None open_external_url_with_in_app_browser: Optional[bool] = None + class Broadcast(TypesBaseModel): title: str thread_igid: str @@ -48,6 +53,7 @@ class Broadcast(TypesBaseModel): creator_igid: Optional[str] = None # Changed from str | None to Optional[str] creator_username: str + class User(TypesBaseModel): pk: str username: str @@ -85,7 +91,13 @@ class User(TypesBaseModel): instagram_location_id: Optional[str] = None interop_messaging_user_fbid: Optional[str] = None - _external_url = field_validator("external_url")(validate_external_url) # Updated to use field_validator + @field_validator("external_url") + @classmethod + def validate_external_url(cls, v): + if v is None or (v.startswith("http") and "://" in v) or isinstance(v, str): + return v + raise ValidationError("external_url must be a URL or string") + class Account(TypesBaseModel): pk: str @@ -102,7 +114,13 @@ class Account(TypesBaseModel): gender: Optional[int] = None email: Optional[str] = None - _external_url = field_validator("external_url")(validate_external_url) # Updated to use field_validator + @field_validator("external_url") + @classmethod + def validate_external_url(cls, v): + if v is None or (v.startswith("http") and "://" in v) or isinstance(v, str): + return v + raise ValidationError("external_url must be a URL or string") + class UserShort(TypesBaseModel): def __hash__(self): @@ -300,6 +318,7 @@ class StorySticker(TypesBaseModel): story_link: Optional[StoryStickerLink] = None extra: Optional[dict] = {} + class StoryPoll(TypesBaseModel): id: Optional[str] = None type: Optional[str] = "poll" @@ -319,6 +338,7 @@ class StoryPoll(TypesBaseModel): options: list extra: Optional[dict] = {} + class StoryBuild(TypesBaseModel): mentions: List[StoryMention] path: FilePath diff --git a/instagrapi/utils.py b/instagrapi/utils.py index d83b1a12..152f5df6 100644 --- a/instagrapi/utils.py +++ b/instagrapi/utils.py @@ -5,6 +5,7 @@ import string import time import urllib + from .exceptions import ValidationError diff --git a/mkdocs.yml b/mkdocs.yml index a3ccfe29..15ef9a77 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,7 +22,7 @@ nav: - Track: usage-guide/track.md - User: usage-guide/user.md - Account: usage-guide/account.md - - Best Practices: best-practices.md + - Best Practices: usage-guide/best-practices.md - Development Guide: development-guide.md - Exceptions: exceptions.md theme: material @@ -36,18 +36,18 @@ markdown_extensions: - pymdownx.keys - pymdownx.superfences plugins: + - search + - autorefs - mkdocstrings: handlers: python: selection: - docstring_style: "restructured-text" + docstring_style: "sphinx" rendering: heading_level: 3 show_root_heading: True show_source: False show_root_full_path: False - - mkdocstrings_patch_type_aliases - extra: version: provider: mike diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0e4c5b72 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "instagrapi" +version = "2.2.0" +authors = [ + {name = "Mark Subzeroid", email = "143403577+subzeroid@users.noreply.github.com"} +] +description = "Fast and effective Instagram Private API wrapper" +readme = {content-type = "text/markdown", text = """ +Fast and effective Instagram Private API wrapper (public+private requests and challenge resolver). + +Use the most recent version of the API from Instagram. + +Features: + +1. Performs Public API (web, anonymous) or Private API (mobile app, authorized) + requests depending on the situation (to avoid Instagram limits) +2. Challenge Resolver have Email (as well as recipes for automating receive a code from email) and SMS handlers +3. Support upload a Photo, Video, IGTV, Clips (Reels), Albums and Stories +4. Support work with User, Media, Insights, Collections, Location (Place), Hashtag and Direct objects +5. Like, Follow, Edit account (Bio) and much more else +6. Insights by account, posts and stories +7. Build stories with custom background, font animation, swipe up link and mention users +8. In the next release, account registration and captcha passing will appear +"""} +license = {text = "MIT"} +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +keywords = [ + "instagram private api", + "instagram-private-api", + "instagram api", + "instagram-api", + "instagram", + "instagram-scraper", + "instagram-client", + "instagram-stories", + "instagram-feed", + "instagram-reels", + "instagram-insights", + "downloader", + "uploader", + "videos", + "photos", + "albums", + "igtv", + "reels", + "stories", + "pictures", + "instagram-user-photos", + "instagram-photos", + "instagram-metadata", + "instagram-downloader", + "instagram-uploader", + "instagram-note", +] +dependencies = [ + "requests==2.32.4", + "PySocks==1.7.1", + "pydantic==2.11.7", + "moviepy==1.0.3", + "pycryptodomex==3.23.0", +] + +[project.urls] +Homepage = "https://github.com/subzeroid/instagrapi" +Repository = "https://github.com/subzeroid/instagrapi" + +[project.optional-dependencies] +test = [ + "flake8==7.3.0", + "Pillow==11.2.1", + "isort==6.0.1", + "bandit==1.8.5", + "mike==2.1.3", + "markdown-include==0.8.1", + "mkdocs-material==9.6.14", + "mkdocs-minify-html-plugin>=0.3.1", + "mkdocstrings==0.29.1", + "pytest-xdist==3.7.0", + "pytest~=8.4.0", +] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["instagrapi*"] diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..af1a15b3 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,14 @@ +mike==2.0.0 + +# mike implicitly depends on pkg_resources from setuptools. +# It has been addressed as part of the work towards 2.0, but that has not been released yet +# https://github.com/jimporter/mike/issues/148 +setuptools==68.2.2 + +markdown-include==0.8.1 +mkdocs==1.5.3 +mkdocs-material==9.5.10 +mkdocs-minify-plugin==0.8.0 +mkdocs-redirects==1.2.1 +mkdocstrings[python]==0.24.0 +mkdocs-autorefs>=1.0.0 diff --git a/requirements-test.txt b/requirements-test.txt index b78505a7..8c172c7b 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,13 +1,6 @@ -flake8==7.2.0 +flake8==7.3.0 Pillow==11.2.1 isort==6.0.1 -bandit==1.8.3 -mike==2.1.3 -markdown-include==0.8.1 -mkdocs-material==9.6.14 -mkdocs-minify-plugin==0.8.0 -mkdocstrings==0.29.1 -./util/mkdocs-redirects -./util/mkdocstrings_patch_type_aliases +bandit==1.8.5 pytest-xdist==3.7.0 pytest~=8.4.0 diff --git a/requirements.lock b/requirements.lock index 506af6e5..c26c9210 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,21 +1,22 @@ # THIS IS AN AUTOGENERATED LOCKFILE. DO NOT EDIT MANUALLY. -certifi==2020.12.5 -chardet==4.0.0 +annotated-types==0.7.0 +certifi==2025.6.15 +charset-normalizer==3.4.2 decorator==4.4.2 -idna==2.10 -imageio==2.9.0 -imageio-ffmpeg==0.4.3 +idna==3.10 +imageio==2.37.0 +imageio-ffmpeg==0.6.0 moviepy==1.0.3 -numpy==1.20.2 -Pillow==8.2.0 -pip==21.0.1 -proglog==0.1.9 -pycryptodomex==3.9.9 -pydantic==2.5.2 +numpy==2.3.1 +pillow==11.2.1 +pip==25.1.1 +proglog==0.1.12 +pycryptodomex==3.23.0 +pydantic==2.11.7 +pydantic_core==2.33.2 PySocks==1.7.1 -requests==2.25.1 -setuptools==53.0.0 -tqdm==4.60.0 -typing-extensions==3.7.4.3 -urllib3==1.26.4 -wheel==0.36.2 +requests==2.32.4 +tqdm==4.67.1 +typing-inspection==0.4.1 +typing_extensions==4.14.0 +urllib3==2.5.0 diff --git a/requirements.txt b/requirements.txt index 89f6de0e..31163fb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests==2.32.4 PySocks==1.7.1 -pydantic==2.11.6 +pydantic==2.11.7 moviepy==1.0.3 pycryptodomex==3.23.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index eecb68e9..00000000 --- a/setup.py +++ /dev/null @@ -1,82 +0,0 @@ -from setuptools import find_packages, setup - -long_description = """ -Fast and effective Instagram Private API wrapper (public+private requests and challenge resolver). - -Use the most recent version of the API from Instagram. - -Features: - -1. Performs Public API (web, anonymous) or Private API (mobile app, authorized) - requests depending on the situation (to avoid Instagram limits) -2. Challenge Resolver have Email (as well as recipes for automating receive a code from email) and SMS handlers -3. Support upload a Photo, Video, IGTV, Clips (Reels), Albums and Stories -4. Support work with User, Media, Insights, Collections, Location (Place), Hashtag and Direct objects -5. Like, Follow, Edit account (Bio) and much more else -6. Insights by account, posts and stories -7. Build stories with custom background, font animation, swipe up link and mention users -8. In the next release, account registration and captcha passing will appear -""" - -requirements = [ - "requests<3.0,>=2.25.1", - "PySocks==1.7.1", - "pydantic==2.11.6", - "pycryptodomex==3.23.0", -] -# requirements = [ -# line.strip() -# for line in open('requirements.txt').readlines() -# ] - -setup( - name="instagrapi", - version="2.1.5", - author="Mark Subzeroid", - author_email="143403577+subzeroid@users.noreply.github.com", - license="MIT", - url="https://github.com/subzeroid/instagrapi", - install_requires=requirements, - keywords=[ - "instagram private api", - "instagram-private-api", - "instagram api", - "instagram-api", - "instagram", - "instagram-scraper", - "instagram-client", - "instagram-stories", - "instagram-feed", - "instagram-reels", - "instagram-insights", - "downloader", - "uploader", - "videos", - "photos", - "albums", - "igtv", - "reels", - "stories", - "pictures", - "instagram-user-photos", - "instagram-photos", - "instagram-metadata", - "instagram-downloader", - "instagram-uploader", - "instagram-note", - ], - description="Fast and effective Instagram Private API wrapper", - long_description=long_description, - long_description_content_type="text/markdown", - packages=find_packages(), - python_requires=">=3.9", - include_package_data=True, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], -) diff --git a/util/mkdocs-redirects/mkdocs-redirects/plugin.py b/util/mkdocs-redirects/mkdocs-redirects/plugin.py deleted file mode 100644 index 19e1e9cf..00000000 --- a/util/mkdocs-redirects/mkdocs-redirects/plugin.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -import os -import textwrap -from urllib.parse import urlparse - -from mkdocs import utils -from mkdocs.config import config_options -from mkdocs.plugins import BasePlugin - -log = logging.getLogger("mkdocs.plugin.redirects") -log.addFilter(utils.warning_filter) - - -def write_html(site_dir, old_path, new_path): - """Write an HTML file in the site_dir with a meta redirect to the new page""" - # Determine all relevant paths - old_path_abs = os.path.join(site_dir, old_path) - old_dir = os.path.dirname(old_path) - old_dir_abs = os.path.dirname(old_path_abs) - - # Create parent directories if they don't exist - if not os.path.exists(old_dir_abs): - log.debug("Creating directory '%s'", old_dir) - os.makedirs(old_dir_abs) - - # Write the HTML redirect file in place of the old file - with open(old_path_abs, "w") as f: - log.debug("Creating redirect: '%s' -> '%s'", old_path, new_path) - f.write( - textwrap.dedent( - """ - - - - - Redirecting... - - - - - - - Redirecting... - - - """ - ).format(url=new_path) - ) - - -def get_relative_html_path(old_page, new_page, use_directory_urls): - """Return the relative path from the old html path to the new html path""" - old_path = get_html_path(old_page, use_directory_urls) - new_path = get_html_path(new_page, use_directory_urls) - - if use_directory_urls: - # remove /index.html from end of path - new_path = os.path.dirname(new_path) - - relative_path = os.path.relpath(new_path, start=os.path.dirname(old_path)) - - if use_directory_urls: - relative_path = relative_path + "/" - - return relative_path - - -def get_html_path(path, use_directory_urls): - """Return the HTML file path for a given markdown file""" - parent, filename = os.path.split(path) - name_orig, ext = os.path.splitext(filename) - - # Directory URLs require some different logic. This mirrors mkdocs' internal logic. - if use_directory_urls: - - # Both `index.md` and `README.md` files are normalized to `index.html` during build - name = "index" if name_orig.lower() in ("index", "readme") else name_orig - - # If it's name is `index`, then that means it's the "homepage" of a directory, so should get placed in that dir - if name == "index": - return os.path.join(parent, "index.html") - - # Otherwise, it's a file within that folder, so it should go in its own directory to resolve properly - else: - return os.path.join(parent, name, "index.html") - - # Just use the original name if Directory URLs aren't used - else: - return os.path.join(parent, (name_orig + ".html")) - - -class RedirectPlugin(BasePlugin): - # Any options that this plugin supplies should go here. - config_scheme = ( - ( - "redirect_maps", - config_options.Type(dict, default={}), - ), # note the trailing comma - ) - - # Build a list of redirects on file generation - def on_files(self, files, config, **kwargs): - self.redirects = self.config.get("redirect_maps", {}) - - # SHIM! Produce a warning if the old root-level 'redirects' config is present - if config.get("redirects"): - log.warn( - "The root-level 'redirects:' setting is not valid and has been changed in version 1.0! " - "The plugin-level 'redirect-map' must be used instead. See https://git.io/fjdBN" - ) - - # Validate user-provided redirect "old files" - for page_old in self.redirects.keys(): - if not utils.is_markdown_file(page_old): - log.warn( - "redirects plugin: '%s' is not a valid markdown file!", page_old - ) - - # Build a dict of known document pages to validate against later - self.doc_pages = {} - for ( - page - ) in files.documentation_pages(): # object type: mkdocs.structure.files.File - self.doc_pages[page.src_path.replace("\\", "/")] = page - - # Create HTML files for redirects after site dir has been built - def on_post_build(self, config, **kwargs): - - # Determine if 'use_directory_urls' is set - use_directory_urls = config.get("use_directory_urls") - - # Walk through the redirect map and write their HTML files - for page_old, page_new in self.redirects.items(): - - # External redirect targets are easy, just use it as the target path - if page_new.lower().startswith(("http://", "https://")): - dest_path = page_new - - elif page_new in self.doc_pages: - dest_path = get_relative_html_path( - page_old, page_new, use_directory_urls - ) - - # If the redirect target isn't external or a valid internal page, throw an error - # Note: we use 'warn' here specifically; mkdocs treats warnings specially when in strict mode - else: - log.warn("Redirect target '%s' does not exist!", page_new) - continue - - # DO IT! - write_html( - config["site_dir"], - get_html_path(page_old, use_directory_urls), - dest_path, - ) diff --git a/util/mkdocs-redirects/setup.py b/util/mkdocs-redirects/setup.py deleted file mode 100644 index f85df839..00000000 --- a/util/mkdocs-redirects/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -import os - -from setuptools import find_packages, setup - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - - -setup( - name="mkdocs-redirects_relative_redirects", - version="1.0.1.b1", - description="A MkDocs plugin for dynamic page redirects to prevent broken links.", - python_requires=">=2.7", - install_requires=[ - "mkdocs>=1.0.4", - ], - extras_require={ - "release": [ - "twine==1.13.0", - ] - }, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], - packages=find_packages(), - entry_points={ - "mkdocs.plugins": ["redirects = mkdocs_redirects.plugin:RedirectPlugin"] - }, -) diff --git a/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/__init__.py b/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/__init__.py deleted file mode 100644 index c3c3c40b..00000000 --- a/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from mkdocstrings_patch_type_aliases._plugin import PatchTypeAliases diff --git a/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/_plugin.py b/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/_plugin.py deleted file mode 100644 index 9e842461..00000000 --- a/util/mkdocstrings_patch_type_aliases/mkdocstrings_patch_type_aliases/_plugin.py +++ /dev/null @@ -1,85 +0,0 @@ -import re -from re import Pattern -from typing import Callable - -from mkdocs.plugins import BasePlugin -from mkdocs.structure.nav import Page - -ReplaceFunc = Callable[[str], str] - - -def simple_replace(look_for: str, replace_with: str) -> ReplaceFunc: - def _replace(text: str) -> str: - return text.replace(look_for, replace_with) - - return _replace - - -def regex_replace(look_for: Pattern, replace_with: str) -> ReplaceFunc: - def _replace(text: str) -> str: - # breakpoint() - return re.sub(look_for, replace_with, text) - - return _replace - - -# Order is significant -REPLACE_FUNCS = [ - # Possible: Python flattens nested unions, so need special handling for normal and nested. - regex_replace( - re.compile( - r"Union\[([][\w, ]+, ([][\w, ]+)), instagrapi\._interaction\._Sentinel]" - ), - r"Possible[Union[\1]]", - ), - regex_replace( - re.compile(r"Union\[([][\w, ]+), instagrapi\._interaction\._Sentinel]"), - r"Possible[\1]", - ), - # StaticOrDynamicValue: Handling for simple and generic types - regex_replace( - re.compile( - r"Union\[(\w+), Callable\[\[Mapping\[str, Union\[bool, str]]], \w+]]" - ), - r"StaticOrDynamicValue[\1]", - ), - regex_replace( - re.compile( - r"Union\[([][\w]+), Callable\[\[Mapping\[str, Union\[bool, str]]], [][\w]+]]" - ), - r"StaticOrDynamicValue[\1]]", - ), - simple_replace("Callable[[Mapping[str, Union[bool, str]]], bool]", "ShouldAsk"), - simple_replace( - "Callable[[str, Mapping[str, Union[bool, str]]], Optional[str]]", "Validator" - ), - simple_replace("Union[Echo, Acknowledge, Question]", "Interaction"), - simple_replace("Mapping[str, Union[bool, str]]", "Answers"), - # Wrapped with square brackets to not replace value in type alias table - simple_replace("[List[str]]", "[OptionList]"), - # Any Optional values that were flattened as a nested union - regex_replace(re.compile(r"Union\[(\w+), NoneType]"), r"Optional[\1]"), - # Sentinel - simple_replace("<_Sentinel.A: 0>", "_Sentinel"), - simple_replace( - """<_Sentinel.""" - """A: 0""" - """>""", - """_Sentinel""", - ), -] - - -class PatchTypeAliases(BasePlugin): - """ - Manually put type aliases back into documentation. - - mkdocstrings shows the actual type instead of the type alias. - https://github.com/pawamoy/pytkdocs/issues/80 - """ - - def on_post_page(self, output: str, page: Page, config: dict) -> str: - if page.title == "Reference": - for replace in REPLACE_FUNCS: - output = replace(output) - return output diff --git a/util/mkdocstrings_patch_type_aliases/setup.py b/util/mkdocstrings_patch_type_aliases/setup.py deleted file mode 100644 index 033e01b7..00000000 --- a/util/mkdocstrings_patch_type_aliases/setup.py +++ /dev/null @@ -1,19 +0,0 @@ -# type: ignore -import setuptools - -setuptools.setup( - name="mkdocstrings_patch_type_aliases", - version="0.1.alpha1", - packages=setuptools.find_packages( - exclude=["*.tests", "*.tests.*", "tests.*", "tests"] - ), - python_requires=">=3.6", - install_requires=[ - "mkdocs~=1.0", - ], - entry_points={ - "mkdocs.plugins": [ - "mkdocstrings_patch_type_aliases = mkdocstrings_patch_type_aliases:PatchTypeAliases", - ] - }, -)