๐Ÿ Tiny CLI to post simultaneously to Mastodon and Bluesky
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Migrates: Python -> Go

+2916 -2833
+1 -6
.gitignore
··· 1 - *.pyc 2 1 .env 3 - .mypy_cache 4 - .pytest_cache/ 5 - .ruff_cache/ 6 - __pycache__/ 7 - dist/ 2 + not-my-ex
+19
.tangled/workflows/tests.yaml
··· 1 + when: 2 + - event: ["push"] 3 + branch: ["main"] 4 + - event: ["pull_request"] 5 + 6 + engine: "nixery" 7 + 8 + dependencies: 9 + nixpkgs: 10 + - go 11 + - golangci-lint 12 + 13 + steps: 14 + - name: Formatting 15 + command: test -z "$(gofmt -l .)" 16 + - name: Linter 17 + command: golangci-lint run 18 + - name: Tests 19 + command: go test ./...
-13
.woodpecker/test.yaml
··· 1 - when: 2 - - event: pull_request 3 - - event: push 4 - branch: main 5 - 6 - matrix: 7 - VERSION: ["3.10", "3.11", "3.12", "3.13", "3.14"] 8 - 9 - steps: 10 - - image: ghcr.io/astral-sh/uv:python${VERSION}-trixie-slim 11 - commands: 12 - - apt-get update && apt-get install -y build-essential libffi-dev python3-dev 13 - - uv run python -m pytest
+57 -27
README.md
··· 1 - # Not my ex [![PyPI](https://img.shields.io/pypi/v/not-my-ex)](https://pypi.org/project/not-my-ex/) [![Tests](https://ci.codeberg.org/api/badges/15214/status.svg)](https://ci.codeberg.org/repos/15214) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/not-my-ex)](https://pypi.org/project/not-my-ex/) 1 + # Not my ex 2 2 3 3 Tiny app to post simultaneously to Mastodon and Bluesky. 4 4 ··· 23 23 24 24 ### Requirements 25 25 26 - * Python 3.9 or newer 26 + * Go 1.26+ 27 27 28 28 <details> 29 29 ··· 62 62 ## Install 63 63 64 64 ```console 65 - $ pip install not-my-ex 65 + $ go install tangled.org/cuducos.me/not-my-ex/cmd@latest 66 66 ``` 67 67 68 68 ## Usage ··· 81 81 82 82 ### API 83 83 84 - ```python 85 - from asyncio import gather 84 + ```go 85 + package main 86 86 87 - from httpx import AsyncClient 87 + import ( 88 + "context" 89 + "log" 88 90 89 - from not_my_ex.auth import EnvAuth 90 - from not_my_ex.bluesky import Bluesky 91 - from not_my_ex.mastodon import Mastodon 92 - from not_my_ex.media import Media 93 - from not_my_ex.post import Post 91 + "tangled.org/cuducos.me/not-my-ex/auth" 92 + "tangled.org/cuducos.me/not-my-ex/client" 93 + "tangled.org/cuducos.me/not-my-ex/post" 94 + "golang.org/x/sync/errgroup" 95 + ) 94 96 97 + func main() { 98 + ctx := context.Background() 95 99 96 - async def main(): 97 - auth = EnvAuth() 98 - media_tasks = tuple( 99 - Media.from_img(path, alt, auth.image_size_limit) 100 - for path, alt in (("taylor.jpg", "Taylor"), ("swift.jpg", "Swift")) 101 - ) 102 - media = await gather(*media_tasks) 100 + data, err := auth.Load(auth.Path) 101 + if err != nil { 102 + log.Fatal(err) 103 + } 103 104 104 - post = Post("Magic, madness, heaven, sin", auth.limit, media, "en") 105 - async with AsyncClient() as http: 106 - post_tasks = tuple(cls(http).post(post) for cls in (Bluesky, Mastodon)) 107 - await gather(*post_tasks) 108 - ``` 105 + bsky, err := client.NewBluesky(ctx, data.Bluesky) 106 + if err != nil { 107 + log.Fatal(err) 108 + } 109 + mstd, err := client.NewMastodon(ctx, data.Mastodon) 110 + if err != nil { 111 + log.Fatal(err) 112 + } 109 113 110 - In `Post`, `limit`, `media` and `lang` are optional. In `Media`, both `alt` and `image_size_limit` are optional. 114 + medias, err := post.NewMedias( 115 + []string{"taylor.jpg", "swift.jpg"}, 116 + []string{"Taylor", "Swift"}, 117 + bsky.Limit().Image, 118 + false, 119 + ) 120 + if err != nil { 121 + log.Fatal(err) 122 + } 111 123 112 - The usage of `auth.limit` and `auth.image_size_limit` makes sure the limits are set according to the authenticated clients. 124 + p, err := post.NewPost("Magic, madness, heaven, sin", medias, "en", bsky.Limit().Text, false) 125 + if err != nil { 126 + log.Fatal(err) 127 + } 128 + 129 + var g errgroup.Group 130 + for _, c := range []client.Client{bsky, mstd} { 131 + g.Go(func() error { 132 + _, err := c.Post(ctx, p) 133 + return err 134 + }) 135 + } 136 + if err := g.Wait(); err != nil { 137 + log.Fatal(err) 138 + } 139 + } 140 + ``` 113 141 114 142 ## Contributing 115 143 116 - Requires [`uv`](https://docs.astral.sh/uv) Python package manager. The tests include [Ruff](https://docs.astral.sh/ruff/) and [Mypy](https://www.mypy-lang.org/): 144 + Format, lint, and test with: 117 145 118 146 ```console 119 - $ uv run pytest 147 + $ gofmt -l . 148 + $ golangci-lint run 149 + $ go test ./... 120 150 ```
+181
auth/auth.go
··· 1 + package auth 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json/v2" 6 + "errors" 7 + "fmt" 8 + "log/slog" 9 + "os" 10 + "path/filepath" 11 + ) 12 + 13 + const ( 14 + defaultBlueskyAgent = "https://bsky.social" 15 + defaultMastodonInstance = "https://mastodon.social" 16 + ) 17 + 18 + var ErrCredsNotFound = errors.New("credentials not found") 19 + 20 + type Bluesky struct { 21 + Agent string 22 + Email *string 23 + Password *string 24 + } 25 + 26 + type Mastodon struct { 27 + Instance string 28 + Token *string 29 + } 30 + 31 + type Data struct { 32 + Bluesky *Bluesky 33 + Mastodon *Mastodon 34 + Language *string 35 + } 36 + 37 + func (d *Data) validate() (*Data, error) { 38 + if d.Bluesky == nil && d.Mastodon == nil { 39 + return nil, ErrCredsNotFound 40 + } 41 + return d, nil 42 + } 43 + 44 + func (d *Data) Save(pth, password string) error { 45 + if err := os.MkdirAll(filepath.Dir(pth), 0700); err != nil { 46 + return fmt.Errorf("could not create directory for %s: %w", pth, err) 47 + } 48 + e, err := newEncryptedAuth(pth, password) 49 + if err != nil { 50 + return err 51 + } 52 + raw, err := json.Marshal(d) 53 + if err != nil { 54 + return fmt.Errorf("could not write to %s: %w", pth, err) 55 + } 56 + f, err := os.Create(pth) 57 + if err != nil { 58 + return fmt.Errorf("could not open or create %s: %w", pth, err) 59 + } 60 + defer func() { 61 + if err := f.Close(); err != nil { 62 + slog.Error("could not properly close file", "path", pth, "error", err) 63 + } 64 + }() 65 + if err := e.encrypt(raw, f); err != nil { 66 + return fmt.Errorf("could not write %s: %w", pth, err) 67 + } 68 + return nil 69 + } 70 + 71 + func loadFromFile(pth, password string) (*Data, error) { 72 + e, err := newEncryptedAuth(pth, password) 73 + if err != nil { 74 + return nil, err 75 + } 76 + b, err := e.decrypt() 77 + if err != nil { 78 + return nil, fmt.Errorf("could not decrypt %s: %w", pth, err) 79 + } 80 + var d Data 81 + if err := json.Unmarshal(b, &d); err != nil { 82 + // backward compatibility with Python pickled auth data file 83 + py, err := unpickle(bytes.NewBuffer(b)) 84 + if err != nil { 85 + return nil, fmt.Errorf("could not read from %s: %w", pth, err) 86 + } 87 + defer func() { 88 + // update the auth file to the new structure 89 + if os.Getenv("DEBUG") == "convert" { 90 + if err := py.Save(pth, password); err != nil { 91 + slog.Error("could not save auth file in new format", "error", err) 92 + } 93 + } 94 + }() 95 + return py, nil 96 + } 97 + return d.validate() 98 + } 99 + 100 + func loadFromEnv() (*Data, error) { 101 + var d Data 102 + e := os.Getenv("NOT_MY_EX_BSKY_EMAIL") 103 + p := os.Getenv("NOT_MY_EX_BSKY_PASSWORD") 104 + if e != "" && p != "" { 105 + var bsky Bluesky 106 + bsky.Email = &e 107 + bsky.Password = &p 108 + bsky.Agent = os.Getenv("NOT_MY_EX_BSKY_AGENT") 109 + if bsky.Agent == "" { 110 + bsky.Agent = defaultBlueskyAgent 111 + } 112 + d.Bluesky = &bsky 113 + } 114 + t := os.Getenv("NOT_MY_EX_MASTODON_TOKEN") 115 + if t != "" { 116 + var m Mastodon 117 + m.Token = &t 118 + m.Instance = os.Getenv("NOT_MY_EX_MASTODON_INSTANCE") 119 + if m.Instance == "" { 120 + m.Instance = defaultMastodonInstance 121 + } 122 + d.Mastodon = &m 123 + } 124 + l := os.Getenv("NOT_MY_EX_DEFAULT_LANG") 125 + if l != "" { 126 + d.Language = &l 127 + } 128 + return d.validate() 129 + } 130 + 131 + func Path() (string, bool, error) { 132 + cache, err := os.UserCacheDir() 133 + if err != nil { 134 + return "", false, fmt.Errorf("could not find cache dir: %w", err) 135 + } 136 + pth := filepath.Join(cache, cacheDir, "auth") 137 + if _, err := os.Stat(pth); err != nil { 138 + if os.IsNotExist(err) { 139 + return pth, false, nil 140 + } 141 + return "", false, err 142 + } 143 + return pth, true, nil 144 + } 145 + 146 + func Load(path func() (string, bool, error)) (*Data, error) { 147 + pth, ok, err := path() 148 + if err != nil { 149 + return nil, fmt.Errorf("could not find auth file: %w", err) 150 + } 151 + if !ok { 152 + return loadFromEnv() 153 + } 154 + pw, err := askWithHiddenInput("Please, enter the password you used to configure not-my-ex:") 155 + if err != nil { 156 + return nil, fmt.Errorf("could not get password: %w", err) 157 + } 158 + return loadFromFile(pth, *pw) 159 + 160 + } 161 + 162 + func Clean() (bool, error) { 163 + pth, ok, err := Path() 164 + if err != nil { 165 + return false, fmt.Errorf("could not find auth file: %w", err) 166 + } 167 + if !ok { 168 + return false, nil 169 + } 170 + c, err := confirm(fmt.Sprintf("Delete %s?", pth)) 171 + if err != nil { 172 + return false, err 173 + } 174 + if !c { 175 + return false, nil 176 + } 177 + if err := os.Remove(pth); err != nil { 178 + return false, fmt.Errorf("could not delete %s: %w", pth, err) 179 + } 180 + return true, nil 181 + }
+120
auth/auth_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + func TestSaveAndLoadFromFile(t *testing.T) { 12 + email := "me@server.org" 13 + password := "forty two" 14 + token := "forty-two" 15 + lang := "pt" 16 + src := Data{ 17 + &Bluesky{"bsky", &email, &password}, 18 + &Mastodon{"tech.lgbt", &token}, 19 + &lang, 20 + } 21 + tmp := t.TempDir() 22 + pth := filepath.Join(tmp, "auth") 23 + if err := src.Save(pth, "admin"); err != nil { 24 + t.Fatalf("expected no error saving auth data, got %s", err) 25 + } 26 + got, err := loadFromFile(pth, "admin") 27 + if err != nil { 28 + t.Fatalf("expected no error loading auth data, got %s", err) 29 + } 30 + if got.Bluesky.Agent != src.Bluesky.Agent { 31 + t.Errorf("expected loaded bluesky agent to be %s, got %s", src.Bluesky.Agent, got.Bluesky.Agent) 32 + } 33 + if *got.Bluesky.Email != *src.Bluesky.Email { 34 + t.Errorf("expected loaded bluesky email to be %s, got %s", *src.Bluesky.Email, *got.Bluesky.Email) 35 + } 36 + if *got.Bluesky.Password != *src.Bluesky.Password { 37 + t.Errorf("expected loaded bluesky password to be %s, got %s", *src.Bluesky.Password, *got.Bluesky.Password) 38 + } 39 + if got.Mastodon.Instance != src.Mastodon.Instance { 40 + t.Errorf("expected loaded mastodon instance to be %s, got %s", src.Mastodon.Instance, got.Mastodon.Instance) 41 + } 42 + if *got.Mastodon.Token != *src.Mastodon.Token { 43 + t.Errorf("expected loaded mastodon token to be %s, got %s", *src.Mastodon.Token, *got.Mastodon.Token) 44 + } 45 + if *got.Language != *src.Language { 46 + t.Errorf("expected loaded language to be %s, got %s", *src.Language, *got.Language) 47 + } 48 + if _, err := loadFromFile(pth, "not admin"); err == nil { 49 + t.Error("expected error when loading with wrong password, got nil") 50 + } 51 + } 52 + 53 + func TestLoadFromEnv(t *testing.T) { 54 + for _, v := range []string{ 55 + "bsky_agent", 56 + "bsky_email", 57 + "bsky_password", 58 + "mastodon_instance", 59 + "mastodon_token", 60 + "default_lang", 61 + } { 62 + k := fmt.Sprintf("NOT_MY_EX_%s", strings.ToUpper(v)) 63 + if err := os.Setenv(k, v); err != nil { 64 + t.Fatalf("expected no error setting %s, got %s", v, err) 65 + } 66 + defer func() { 67 + if err := os.Unsetenv(k); err != nil { 68 + t.Errorf("expected no error unsetting %s, got %s", v, err) 69 + } 70 + }() 71 + } 72 + got, err := loadFromEnv() 73 + if err != nil { 74 + t.Fatalf("expected no error loading form env, got %s", err) 75 + } 76 + if got.Bluesky.Agent != "bsky_agent" { 77 + t.Errorf("expected bluesky agent to be bsky_agent, got %s", got.Bluesky.Agent) 78 + } 79 + if *got.Bluesky.Email != "bsky_email" { 80 + t.Errorf("expected bluesky email to be bsky_email got %s", *got.Bluesky.Email) 81 + } 82 + if *got.Bluesky.Password != "bsky_password" { 83 + t.Errorf("expected bluesky password to be bsky_password, got %s", *got.Bluesky.Password) 84 + } 85 + if got.Mastodon.Instance != "mastodon_instance" { 86 + t.Errorf("expected mastodon instance to be mastodon_instance, got %s", got.Mastodon.Instance) 87 + } 88 + if *got.Mastodon.Token != "mastodon_token" { 89 + t.Errorf("expected mastodon token to be mastodon_token, got %s", *got.Mastodon.Token) 90 + } 91 + if *got.Language != "default_lang" { 92 + t.Errorf("expected language to be default_lang, got %s", *got.Language) 93 + } 94 + } 95 + 96 + func TestLoadBackwardCompatibility(t *testing.T) { 97 + pth := filepath.Join("..", "testdata", "encrypted.pickle") 98 + got, err := loadFromFile(pth, "admin") 99 + if err != nil { 100 + t.Fatalf("expected no error opening pickled auth data, got %s", err) 101 + } 102 + if got.Bluesky.Agent != "bsky" { 103 + t.Errorf("expected bluesky agent to be bsky, got %s", got.Bluesky.Agent) 104 + } 105 + if *got.Bluesky.Email != "me@server.org" { 106 + t.Errorf("expected bluesky email to be me@server.org, got %s", *got.Bluesky.Email) 107 + } 108 + if *got.Bluesky.Password != "forty two" { 109 + t.Errorf("expected bluesky password to be forty two, got %s", *got.Bluesky.Password) 110 + } 111 + if got.Mastodon.Instance != "tech.lgbt" { 112 + t.Errorf("expected mastodon instance to be tech.lgbt, got %s", got.Mastodon.Instance) 113 + } 114 + if *got.Mastodon.Token != "forty-two" { 115 + t.Errorf("expected mastodon token to be forty-two, got %s", *got.Mastodon.Token) 116 + } 117 + if *got.Language != "pt" { 118 + t.Errorf("expected language to be pt, got %s", *got.Language) 119 + } 120 + }
+141
auth/backward.go
··· 1 + // backward compatibility with Python's pickled auth data 2 + package auth 3 + 4 + import ( 5 + "fmt" 6 + "io" 7 + 8 + "github.com/nlpodyssey/gopickle/pickle" 9 + "github.com/nlpodyssey/gopickle/types" 10 + ) 11 + 12 + type PyBlueskyClass struct{} 13 + 14 + func (c *PyBlueskyClass) PyNew(args ...any) (any, error) { 15 + return &PyBlueskyAuth{ 16 + GenericObject: &types.GenericObject{}, 17 + Data: &Bluesky{}, 18 + }, nil 19 + } 20 + 21 + type PyBlueskyAuth struct { 22 + *types.GenericObject 23 + Data *Bluesky 24 + } 25 + 26 + func (p *PyBlueskyAuth) PyDictSet(key, value any) error { 27 + if p.Data == nil { 28 + p.Data = &Bluesky{} 29 + } 30 + if k, ok := key.(string); ok { 31 + switch k { 32 + case "agent": 33 + if v, ok := value.(string); ok { 34 + p.Data.Agent = v 35 + } 36 + case "email": 37 + if v, ok := value.(string); ok { 38 + p.Data.Email = &v 39 + } 40 + case "password": 41 + if v, ok := value.(string); ok { 42 + p.Data.Password = &v 43 + } 44 + } 45 + } 46 + return nil 47 + } 48 + 49 + type PyMastodonClass struct{} 50 + 51 + func (c *PyMastodonClass) PyNew(args ...any) (any, error) { 52 + return &PyMastodon{ 53 + GenericObject: &types.GenericObject{}, 54 + Data: &Mastodon{}, 55 + }, nil 56 + } 57 + 58 + type PyMastodon struct { 59 + *types.GenericObject 60 + Data *Mastodon 61 + } 62 + 63 + func (p *PyMastodon) PyDictSet(key, value any) error { 64 + if p.Data == nil { 65 + p.Data = &Mastodon{} 66 + } 67 + if k, ok := key.(string); ok { 68 + switch k { 69 + case "instance": 70 + if v, ok := value.(string); ok { 71 + p.Data.Instance = v 72 + } 73 + case "token": 74 + if v, ok := value.(string); ok { 75 + p.Data.Token = &v 76 + } 77 + } 78 + } 79 + return nil 80 + } 81 + 82 + type PyAuthClass struct{} 83 + 84 + func (c *PyAuthClass) PyNew(args ...any) (any, error) { 85 + return &PyAuthData{ 86 + GenericObject: &types.GenericObject{}, 87 + Data: &Data{}, 88 + }, nil 89 + } 90 + 91 + type PyAuthData struct { 92 + *types.GenericObject 93 + Data *Data 94 + } 95 + 96 + func (p *PyAuthData) PyDictSet(key, value any) error { 97 + if p.Data == nil { 98 + p.Data = &Data{} 99 + } 100 + if k, ok := key.(string); ok { 101 + switch k { 102 + case "bluesky": 103 + if v, ok := value.(*PyBlueskyAuth); ok { 104 + p.Data.Bluesky = v.Data 105 + } 106 + case "mastodon": 107 + if v, ok := value.(*PyMastodon); ok { 108 + p.Data.Mastodon = v.Data 109 + } 110 + case "language": 111 + if v, ok := value.(string); ok { 112 + p.Data.Language = &v 113 + } 114 + } 115 + } 116 + return nil 117 + } 118 + 119 + func unpickle(src io.Reader) (*Data, error) { 120 + u := pickle.NewUnpickler(src) 121 + u.FindClass = func(mod, name string) (any, error) { 122 + switch name { 123 + case "AuthData": 124 + return &PyAuthClass{}, nil 125 + case "BlueskyAuth": 126 + return &PyBlueskyClass{}, nil 127 + case "MastodonAuth": 128 + return &PyMastodonClass{}, nil 129 + } 130 + return &types.GenericClass{Module: mod, Name: name}, nil 131 + } 132 + obj, err := u.Load() 133 + if err != nil { 134 + return nil, err 135 + } 136 + got, ok := obj.(*PyAuthData) 137 + if !ok { 138 + return nil, fmt.Errorf("loaded object is not PyAuthData, got %T", obj) 139 + } 140 + return got.Data, nil 141 + }
+42
auth/backward_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestUnpickle(t *testing.T) { 10 + pth := filepath.Join("..", "testdata", "auth.pickle") 11 + f, err := os.Open(pth) 12 + if err != nil { 13 + t.Fatalf("expected no error opening %s, got %s", pth, err) 14 + } 15 + defer func() { 16 + if err := f.Close(); err != nil { 17 + t.Errorf("expected no error closing %s, got %s", pth, err) 18 + } 19 + }() 20 + got, err := unpickle(f) 21 + if err != nil { 22 + t.Fatalf("expected no error loading auth file, got %s", err) 23 + } 24 + if got.Bluesky.Agent != "agent x" { 25 + t.Errorf("expected bluesky agent to be agent x, got %s", got.Bluesky.Agent) 26 + } 27 + if *got.Bluesky.Email != "me@server.org" { 28 + t.Errorf("expected bluesky email to be me@server.org, got %s", *got.Bluesky.Email) 29 + } 30 + if *got.Bluesky.Password != "forty two" { 31 + t.Errorf("expected bluesky password to be forty two, got %s", *got.Bluesky.Password) 32 + } 33 + if got.Mastodon.Instance != "tech.lgbt" { 34 + t.Errorf("expected mastodon instance to be tech.lgbt, got %s", got.Mastodon.Instance) 35 + } 36 + if *got.Mastodon.Token != "forty-two" { 37 + t.Errorf("expected mastodon token to be forty-two, got %s", *got.Mastodon.Token) 38 + } 39 + if *got.Language != "pt" { 40 + t.Errorf("expected language to be pt, got %s", *got.Language) 41 + } 42 + }
+106
auth/crypt.go
··· 1 + package auth 2 + 3 + import ( 4 + "crypto/pbkdf2" 5 + "crypto/rand" 6 + "crypto/sha512" 7 + "encoding/base64" 8 + "errors" 9 + "fmt" 10 + "io" 11 + "log/slog" 12 + "math" 13 + "os" 14 + 15 + "github.com/fernet/fernet-go" 16 + ) 17 + 18 + const saltSize = 42 19 + 20 + var ErrFailedToDecrypt = errors.New("failed to decrypt credentials") 21 + 22 + type encryptedData struct { 23 + salt []byte 24 + key *fernet.Key 25 + data []byte 26 + } 27 + 28 + func (e *encryptedData) encrypt(data []byte, w io.Writer) error { 29 + var err error 30 + e.data, err = fernet.EncryptAndSign(data, e.key) 31 + if err != nil { 32 + return fmt.Errorf("could not encrypt data: %w", err) 33 + } 34 + for _, b := range [][]byte{e.salt, e.data} { 35 + if _, err := w.Write(b); err != nil { 36 + return fmt.Errorf("could not write encrypted data: %w", err) 37 + } 38 + } 39 + return nil 40 + } 41 + 42 + func (e *encryptedData) decrypt() ([]byte, error) { 43 + b := fernet.VerifyAndDecrypt(e.data, 0, []*fernet.Key{e.key}) 44 + if b == nil { 45 + return nil, ErrFailedToDecrypt 46 + } 47 + return b, nil 48 + } 49 + 50 + func (e *encryptedData) generateSalt() error { 51 + if _, err := rand.Read(e.salt); err != nil { 52 + return fmt.Errorf("could not generate salt: %w", err) 53 + } 54 + return nil 55 + } 56 + 57 + func (e *encryptedData) addSaltAndDataFromFile(pth string) error { 58 + f, err := os.Open(pth) 59 + if err != nil { 60 + return fmt.Errorf("could not open %s: %w", pth, err) 61 + } 62 + defer func() { 63 + if err := f.Close(); err != nil { 64 + slog.Warn("could not properly close file", "path", pth, "error", err) 65 + } 66 + }() 67 + if _, err := f.Read(e.salt); err != nil { 68 + return fmt.Errorf("could not read salt from %s: %w", pth, err) 69 + } 70 + e.data, err = io.ReadAll(f) 71 + if err != nil { 72 + return fmt.Errorf("could not read data from %s: %w", pth, err) 73 + } 74 + return nil 75 + } 76 + 77 + func newEncryptedAuth(pth, password string) (*encryptedData, error) { 78 + a := encryptedData{salt: make([]byte, saltSize)} 79 + if _, err := os.Stat(pth); err != nil { 80 + if !os.IsNotExist(err) { 81 + return nil, fmt.Errorf("could not get %s info: %w", pth, err) 82 + } 83 + if err := a.generateSalt(); err != nil { 84 + return nil, fmt.Errorf("could not generate salt: %w", err) 85 + } 86 + } else { 87 + if err := a.addSaltAndDataFromFile(pth); err != nil { 88 + return nil, fmt.Errorf("could not read salt: %w", err) 89 + } 90 + } 91 + k, err := pbkdf2.Key( 92 + sha512.New, 93 + password, 94 + a.salt, 95 + int(math.Pow(2, 19)), 96 + 32, 97 + ) 98 + if err != nil { 99 + return nil, fmt.Errorf("could not create encryption key: %w", err) 100 + } 101 + a.key, err = fernet.DecodeKey(base64.URLEncoding.EncodeToString(k)) 102 + if err != nil { 103 + return nil, fmt.Errorf("could not create fernet key: %w", err) 104 + } 105 + return &a, nil 106 + }
+139
auth/input.go
··· 1 + package auth 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "os" 7 + "strings" 8 + "syscall" 9 + "unicode/utf8" 10 + 11 + "golang.org/x/term" 12 + ) 13 + 14 + const cacheDir = "not-my-ex" 15 + 16 + func ask(msg string, fallback *string) (*string, error) { 17 + fmt.Print(msg) 18 + if fallback != nil { 19 + fmt.Printf(" [default: %s]", *fallback) 20 + } 21 + fmt.Print(" ") 22 + r := bufio.NewReader(os.Stdin) 23 + v, err := r.ReadString('\n') 24 + if err != nil { 25 + return nil, fmt.Errorf("could not read input from terminal: %w", err) 26 + } 27 + c := strings.TrimSpace(v) 28 + if c == "" { 29 + if fallback != nil { 30 + return fallback, nil 31 + } 32 + return ask(msg, nil) 33 + } 34 + return &c, nil 35 + } 36 + 37 + func confirm(msg string) (bool, error) { 38 + q := fmt.Sprintf("%s [y/n]", msg) 39 + for { 40 + v, err := ask(q, nil) 41 + if err != nil { 42 + return false, err 43 + } 44 + switch strings.ToLower(*v) { 45 + case "y": 46 + return true, nil 47 + case "n": 48 + return false, nil 49 + } 50 + } 51 + } 52 + 53 + func askWithHiddenInput(msg string) (*string, error) { 54 + fmt.Print(msg) 55 + fmt.Print(" ") 56 + defer fmt.Print("\n") 57 + pw, err := term.ReadPassword(int(syscall.Stdin)) 58 + if err != nil { 59 + return nil, fmt.Errorf("could not read input from terminal: %w", err) 60 + } 61 + out := string(pw) 62 + return &out, nil 63 + } 64 + 65 + func Create(pth string) error { 66 + var d Data 67 + b, err := confirm("Do you want to configure Bluesky?") 68 + if err != nil { 69 + return err 70 + } 71 + if b { 72 + e, err := ask("Email you used to create your Bluesky account:", nil) 73 + if err != nil { 74 + return err 75 + } 76 + p, err := askWithHiddenInput("App password you created for not-my-ex (see https://bsky.app/settings/app-passwords):") 77 + if err != nil { 78 + return err 79 + } 80 + fba := defaultBlueskyAgent 81 + a, err := ask("Bluesky requires a custom agent name:", &fba) 82 + if err != nil { 83 + return err 84 + } 85 + d.Bluesky = &Bluesky{*a, e, p} 86 + } 87 + m, err := confirm("Do you want to configure Mastodon?") 88 + if err != nil { 89 + return err 90 + } 91 + if m { 92 + t, err := askWithHiddenInput("Token for not-my-ex (visit Settings ยป Development and create one with `write:statuses` and `write:media` permissions):") 93 + if err != nil { 94 + return err 95 + } 96 + fbi := defaultMastodonInstance 97 + i, err := ask("Mastodon instance:", &fbi) 98 + if err != nil { 99 + return err 100 + } 101 + d.Mastodon = &Mastodon{*i, t} 102 + } 103 + if _, err := d.validate(); err != nil { 104 + return err 105 + } 106 + for { 107 + lang, err := ask("Preferred language for posts (2-letter ISO 639-1 code)", nil) 108 + if err != nil { 109 + return err 110 + } 111 + if *lang != "" && utf8.RuneCountInString(*lang) != 2 { 112 + fmt.Fprintf(os.Stderr, "%s is an invalid 2-letter ISO 639-1 language code.\n", *lang) 113 + continue 114 + } 115 + if *lang != "" { 116 + d.Language = lang 117 + } 118 + break 119 + } 120 + fmt.Println("Create a password to protect the local credentials you just configured for not-my-ex. An empty password is allowed but discouraged.") 121 + for { 122 + pw, err := askWithHiddenInput("Password:") 123 + if err != nil { 124 + return err 125 + } 126 + co, err := askWithHiddenInput("Confirm password:") 127 + if err != nil { 128 + return err 129 + } 130 + if *pw != *co { 131 + fmt.Fprint(os.Stderr, "Passwords do not match, try again, please.") 132 + continue 133 + } 134 + if err := d.Save(pth, *pw); err != nil { 135 + return err 136 + } 137 + return nil 138 + } 139 + }
+234
client/bluesky.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "regexp" 10 + "strings" 11 + "time" 12 + "unicode" 13 + 14 + "golang.org/x/sync/errgroup" 15 + 16 + "tangled.org/cuducos.me/not-my-ex/auth" 17 + "tangled.org/cuducos.me/not-my-ex/post" 18 + "github.com/bluesky-social/indigo/api/atproto" 19 + "github.com/bluesky-social/indigo/api/bsky" 20 + "github.com/bluesky-social/indigo/atproto/atclient" 21 + "github.com/bluesky-social/indigo/atproto/syntax" 22 + "github.com/bluesky-social/indigo/lex/util" 23 + ) 24 + 25 + var ( 26 + urlPattern = regexp.MustCompile(`https?://[\w_-]+(?:(?:\.[\w_-]+)+)(?:[\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?`) 27 + hashtagPattern = regexp.MustCompile(`#\S+`) 28 + ) 29 + 30 + type Bluesky struct { 31 + client *atclient.APIClient 32 + } 33 + 34 + func (*Bluesky) Name() string { return "Bluesky" } 35 + func (*Bluesky) Emoji() string { return "๐Ÿฆ‹" } 36 + func (*Bluesky) Limit() Limit { return Limit{300, 1 << 20} } 37 + 38 + func stripTrailingPunctuation(s string) string { 39 + return strings.TrimRightFunc(s, func(r rune) bool { 40 + return unicode.IsPunct(r) || unicode.IsSymbol(r) 41 + }) 42 + } 43 + 44 + func buildFacets(src []byte) []*bsky.RichtextFacet { 45 + var out []*bsky.RichtextFacet 46 + var rs [][2]int 47 + 48 + for _, loc := range urlPattern.FindAllIndex(src, -1) { 49 + rs = append(rs, [2]int{loc[0], loc[1]}) 50 + out = append(out, &bsky.RichtextFacet{ 51 + Index: &bsky.RichtextFacet_ByteSlice{ByteStart: int64(loc[0]), ByteEnd: int64(loc[1])}, 52 + Features: []*bsky.RichtextFacet_Features_Elem{ 53 + {RichtextFacet_Link: &bsky.RichtextFacet_Link{Uri: string(src[loc[0]:loc[1]])}}, 54 + }, 55 + }) 56 + } 57 + 58 + for _, loc := range hashtagPattern.FindAllIndex(src, -1) { 59 + in := false 60 + for _, r := range rs { 61 + if loc[0] > r[0] && loc[0] < r[1] { 62 + in = true 63 + break 64 + } 65 + } 66 + if in { 67 + continue 68 + } 69 + 70 + h := string(src[loc[0]:loc[1]]) 71 + tag := strings.TrimPrefix(h, "#") 72 + if strings.TrimFunc(tag, unicode.IsDigit) == "" { 73 + continue 74 + } 75 + 76 + h = stripTrailingPunctuation(h) 77 + if len(h) < 2 { 78 + continue 79 + } 80 + 81 + tag = strings.TrimPrefix(h, "#") 82 + if tag == "" { 83 + continue 84 + } 85 + 86 + out = append(out, &bsky.RichtextFacet{ 87 + Index: &bsky.RichtextFacet_ByteSlice{ByteStart: int64(loc[0]), ByteEnd: int64(loc[0] + len(h))}, 88 + Features: []*bsky.RichtextFacet_Features_Elem{ 89 + {RichtextFacet_Tag: &bsky.RichtextFacet_Tag{Tag: tag}}, 90 + }, 91 + }) 92 + } 93 + 94 + return out 95 + } 96 + 97 + func (b *Bluesky) uploadBlob(ctx context.Context, mime string, r io.Reader) (*util.LexBlob, error) { 98 + var out atproto.RepoUploadBlob_Output 99 + if err := b.client.LexDo(ctx, "POST", mime, "com.atproto.repo.uploadBlob", nil, r, &out); err != nil { 100 + return nil, fmt.Errorf("could not upload blob: %w", err) 101 + } 102 + return out.Blob, nil 103 + } 104 + 105 + func (b *Bluesky) uploadMedia(ctx context.Context, m *post.Media) (*bsky.EmbedImages_Image, error) { 106 + w, h, err := m.Dimensions() 107 + if err != nil { 108 + return nil, fmt.Errorf("could not get image dimensions: %w", err) 109 + } 110 + 111 + if _, err := m.Reader.Seek(0, io.SeekStart); err != nil { 112 + return nil, fmt.Errorf("could not seek media: %w", err) 113 + } 114 + 115 + blob, err := b.uploadBlob(ctx, m.Mime, m.Reader) 116 + if err != nil { 117 + return nil, fmt.Errorf("could not upload image: %w", err) 118 + } 119 + 120 + img := &bsky.EmbedImages_Image{Alt: m.Alt, Image: blob} 121 + if w > 0 && h > 0 { 122 + img.AspectRatio = &bsky.EmbedDefs_AspectRatio{Width: int64(w), Height: int64(h)} 123 + } 124 + 125 + return img, nil 126 + } 127 + 128 + func (b *Bluesky) Post(ctx context.Context, p *post.Post) (string, error) { 129 + fp := &bsky.FeedPost{ 130 + Text: p.Text, 131 + CreatedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), 132 + Langs: []string{p.Language}, 133 + } 134 + 135 + if fs := buildFacets([]byte(p.Text)); len(fs) > 0 { 136 + fp.Facets = fs 137 + } 138 + 139 + if len(p.Media) > 0 { 140 + imgs := make([]*bsky.EmbedImages_Image, len(p.Media)) 141 + var g errgroup.Group 142 + for i, m := range p.Media { 143 + g.Go(func() error { 144 + img, err := b.uploadMedia(ctx, m) 145 + if err != nil { 146 + return err 147 + } 148 + imgs[i] = img 149 + return nil 150 + }) 151 + } 152 + if err := g.Wait(); err != nil { 153 + return "", err 154 + } 155 + fp.Embed = &bsky.FeedPost_Embed{EmbedImages: &bsky.EmbedImages{Images: imgs}} 156 + } else if len(fp.Facets) > 0 { 157 + for _, f := range fp.Facets { 158 + if len(f.Features) == 0 || f.Features[0].RichtextFacet_Link == nil { 159 + continue 160 + } 161 + card := fetchCard(ctx, f.Features[0].RichtextFacet_Link.Uri) 162 + if card == nil { 163 + break 164 + } 165 + ext := &bsky.EmbedExternal_External{ 166 + Uri: card.uri, 167 + Title: card.title, 168 + Description: card.description, 169 + } 170 + if len(card.thumbBytes) > 0 { 171 + blob, err := b.uploadBlob(ctx, card.thumbMime, bytes.NewReader(card.thumbBytes)) 172 + if err != nil { 173 + slog.Warn("could not upload card thumbnail", "error", err) 174 + } else { 175 + ext.Thumb = blob 176 + } 177 + } 178 + fp.Embed = &bsky.FeedPost_Embed{EmbedExternal: &bsky.EmbedExternal{External: ext}} 179 + break 180 + } 181 + } 182 + 183 + var rec *atproto.RepoCreateRecord_Output 184 + if err := withRetry(ctx, 7, 500*time.Millisecond, func() error { 185 + var e error 186 + rec, e = atproto.RepoCreateRecord( 187 + ctx, 188 + b.client, 189 + &atproto.RepoCreateRecord_Input{ 190 + Repo: b.client.AccountDID.String(), 191 + Collection: "app.bsky.feed.post", 192 + Record: &util.LexiconTypeDecoder{Val: fp}, 193 + }, 194 + ) 195 + return e 196 + }); err != nil { 197 + return "", fmt.Errorf("could not post to bluesky: %w", err) 198 + } 199 + 200 + uri, err := syntax.ParseATURI(rec.Uri) 201 + if err != nil { 202 + return "", fmt.Errorf("could not parse post URI: %w", err) 203 + } 204 + return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", b.client.AccountDID, uri.RecordKey()), nil 205 + } 206 + 207 + func withRetry(ctx context.Context, maxAttempts int, wait time.Duration, fn func() error) error { 208 + var err error 209 + for i := range maxAttempts { 210 + if err = fn(); err == nil { 211 + return nil 212 + } 213 + if i == maxAttempts-1 { 214 + break 215 + } 216 + select { 217 + case <-ctx.Done(): 218 + return ctx.Err() 219 + case <-time.After(wait): 220 + } 221 + if wait < 32*time.Second { 222 + wait *= 2 223 + } 224 + } 225 + return err 226 + } 227 + 228 + func NewBluesky(ctx context.Context, auth *auth.Bluesky) (*Bluesky, error) { 229 + api, err := atclient.LoginWithPasswordHost(ctx, auth.Agent, *auth.Email, *auth.Password, "", nil) 230 + if err != nil { 231 + return nil, fmt.Errorf("failed to authenticate bluesky client: %w", err) 232 + } 233 + return &Bluesky{api}, nil 234 + }
+159
client/bluesky_test.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestBuildFacetsURL(t *testing.T) { 11 + src := []byte("check out https://example.com today") 12 + got := buildFacets(src) 13 + if len(got) != 1 { 14 + t.Fatalf("expected 1 facet, got %d", len(got)) 15 + } 16 + f := got[0] 17 + if f.Features[0].RichtextFacet_Link == nil { 18 + t.Fatal("expected link facet, got nil") 19 + } 20 + if f.Features[0].RichtextFacet_Link.Uri != "https://example.com" { 21 + t.Errorf("expected URI to be %q, got %q", "https://example.com", f.Features[0].RichtextFacet_Link.Uri) 22 + } 23 + if f.Index.ByteStart != 10 { 24 + t.Errorf("expected ByteStart to be 10, got %d", f.Index.ByteStart) 25 + } 26 + if f.Index.ByteEnd != 29 { 27 + t.Errorf("expected ByteEnd to be 29, got %d", f.Index.ByteEnd) 28 + } 29 + } 30 + 31 + func TestBuildFacetsHashtag(t *testing.T) { 32 + src := []byte("loving #golang today") 33 + got := buildFacets(src) 34 + if len(got) != 1 { 35 + t.Fatalf("expected 1 facet, got %d", len(got)) 36 + } 37 + f := got[0] 38 + if f.Features[0].RichtextFacet_Tag == nil { 39 + t.Fatal("expected tag facet, got nil") 40 + } 41 + if f.Features[0].RichtextFacet_Tag.Tag != "golang" { 42 + t.Errorf("expected tag to be %q, got %q", "golang", f.Features[0].RichtextFacet_Tag.Tag) 43 + } 44 + } 45 + 46 + func TestBuildFacetsHashtagAllDigitsSkipped(t *testing.T) { 47 + src := []byte("issue #42 is fixed") 48 + got := buildFacets(src) 49 + if len(got) != 0 { 50 + t.Errorf("expected 0 facets for all-digit hashtag, got %d", len(got)) 51 + } 52 + } 53 + 54 + func TestBuildFacetsHashtagInsideURLSkipped(t *testing.T) { 55 + src := []byte("see https://example.com/wiki/CBOR#42 for details") 56 + got := buildFacets(src) 57 + if len(got) != 1 { 58 + t.Fatalf("expected 1 facet (URL only), got %d", len(got)) 59 + } 60 + if got[0].Features[0].RichtextFacet_Link == nil { 61 + t.Error("expected link facet, got tag facet") 62 + } 63 + } 64 + 65 + func TestBuildFacetsURLAndHashtag(t *testing.T) { 66 + src := []byte("โœจ example mentioning @atproto.com to #share the URL ๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ https://en.wikipedia.org/wiki/CBOR#42.") 67 + got := buildFacets(src) 68 + if len(got) != 2 { 69 + t.Fatalf("expected 2 facets, got %d", len(got)) 70 + } 71 + // first should be the URL 72 + if got[0].Features[0].RichtextFacet_Link == nil { 73 + t.Error("expected first facet to be a link") 74 + } 75 + // second should be the hashtag 76 + if got[1].Features[0].RichtextFacet_Tag == nil { 77 + t.Error("expected second facet to be a tag") 78 + } 79 + if got[1].Features[0].RichtextFacet_Tag.Tag != "share" { 80 + t.Errorf("expected tag to be %q, got %q", "share", got[1].Features[0].RichtextFacet_Tag.Tag) 81 + } 82 + } 83 + 84 + func TestBuildFacetsHashtagTrailingPunctuationStripped(t *testing.T) { 85 + src := []byte("nice #golang. period") 86 + got := buildFacets(src) 87 + if len(got) != 1 { 88 + t.Fatalf("expected 1 facet, got %d", len(got)) 89 + } 90 + if got[0].Features[0].RichtextFacet_Tag.Tag != "golang" { 91 + t.Errorf("expected tag to be %q, got %q", "golang", got[0].Features[0].RichtextFacet_Tag.Tag) 92 + } 93 + } 94 + 95 + func TestBuildFacetsEmpty(t *testing.T) { 96 + got := buildFacets([]byte("no links or hashtags here")) 97 + if len(got) != 0 { 98 + t.Errorf("expected 0 facets, got %d", len(got)) 99 + } 100 + } 101 + 102 + func TestWithRetrySuccess(t *testing.T) { 103 + var n int 104 + err := withRetry(context.Background(), 3, time.Millisecond, func() error { 105 + n++ 106 + return nil 107 + }) 108 + if err != nil { 109 + t.Fatalf("expected no error, got %s", err) 110 + } 111 + if n != 1 { 112 + t.Errorf("expected 1 attempt, got %d", n) 113 + } 114 + } 115 + 116 + func TestWithRetryEventualSuccess(t *testing.T) { 117 + var n int 118 + err := withRetry(context.Background(), 5, time.Millisecond, func() error { 119 + n++ 120 + if n < 3 { 121 + return errors.New("transient") 122 + } 123 + return nil 124 + }) 125 + if err != nil { 126 + t.Fatalf("expected no error, got %s", err) 127 + } 128 + if n != 3 { 129 + t.Errorf("expected 3 attempts, got %d", n) 130 + } 131 + } 132 + 133 + func TestWithRetryExhausted(t *testing.T) { 134 + var n int 135 + err := withRetry(context.Background(), 3, time.Millisecond, func() error { 136 + n++ 137 + return errors.New("always fails") 138 + }) 139 + if err == nil { 140 + t.Error("expected error, got nil") 141 + } 142 + if n != 3 { 143 + t.Errorf("expected 3 attempts, got %d", n) 144 + } 145 + } 146 + 147 + func TestWithRetryContextCancel(t *testing.T) { 148 + ctx, cancel := context.WithCancel(context.Background()) 149 + cancel() 150 + err := withRetry(ctx, 5, time.Millisecond, func() error { 151 + return errors.New("fail") 152 + }) 153 + if err == nil { 154 + t.Error("expected error, got nil") 155 + } 156 + if !errors.Is(err, context.Canceled) { 157 + t.Errorf("expected context.Canceled, got %v", err) 158 + } 159 + }
+108
client/card.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "regexp" 11 + "strings" 12 + ) 13 + 14 + type card struct { 15 + uri string 16 + title string 17 + description string 18 + thumbBytes []byte 19 + thumbMime string 20 + } 21 + 22 + var ( 23 + metaTagPattern = regexp.MustCompile(`(?i)<meta\s+([^>]+?)/?>`) 24 + attrPattern = regexp.MustCompile(`(?i)([\w-]+)\s*=\s*["']([^"']*)["']`) 25 + ) 26 + 27 + func parseOGMeta(html []byte) map[string]string { 28 + out := make(map[string]string) 29 + for _, t := range metaTagPattern.FindAll(html, -1) { 30 + a := make(map[string]string) 31 + for _, m := range attrPattern.FindAllSubmatch(t, -1) { 32 + a[strings.ToLower(string(m[1]))] = string(m[2]) 33 + } 34 + p, c := a["property"], a["content"] 35 + if strings.HasPrefix(p, "og:") && c != "" { 36 + out[p] = c 37 + } 38 + } 39 + return out 40 + } 41 + 42 + func fetchBytes(ctx context.Context, url string) ([]byte, string, error) { 43 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 44 + if err != nil { 45 + return nil, "", fmt.Errorf("could not create request: %w", err) 46 + } 47 + resp, err := http.DefaultClient.Do(req) 48 + if err != nil { 49 + return nil, "", fmt.Errorf("request failed: %w", err) 50 + } 51 + defer func() { 52 + if err := resp.Body.Close(); err != nil { 53 + slog.Warn("could not close response body", "error", err) 54 + } 55 + }() 56 + if resp.StatusCode != http.StatusOK { 57 + return nil, "", fmt.Errorf("unexpected status %d", resp.StatusCode) 58 + } 59 + b, err := io.ReadAll(resp.Body) 60 + if err != nil { 61 + return nil, "", fmt.Errorf("could not read response: %w", err) 62 + } 63 + ct := resp.Header.Get("Content-Type") 64 + if i := strings.Index(ct, ";"); i != -1 { 65 + ct = ct[:i] 66 + } 67 + return b, strings.TrimSpace(ct), nil 68 + } 69 + 70 + func fetchCard(ctx context.Context, u string) *card { 71 + b, _, err := fetchBytes(ctx, u) 72 + if err != nil { 73 + return nil 74 + } 75 + 76 + m := parseOGMeta(b) 77 + if m["og:title"] == "" || m["og:url"] == "" { 78 + return nil 79 + } 80 + 81 + c := &card{ 82 + uri: m["og:url"], 83 + title: m["og:title"], 84 + description: m["og:description"], 85 + } 86 + 87 + img := m["og:image"] 88 + if img == "" { 89 + return c 90 + } 91 + if !strings.Contains(img, "://") { 92 + base, err := url.Parse(u) 93 + if err == nil { 94 + ref, err := url.Parse(img) 95 + if err == nil { 96 + img = base.ResolveReference(ref).String() 97 + } 98 + } 99 + } 100 + 101 + t, ct, err := fetchBytes(ctx, img) 102 + if err != nil || ct == "" { 103 + return c 104 + } 105 + c.thumbBytes = t 106 + c.thumbMime = ct 107 + return c 108 + }
+145
client/card_test.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + ) 12 + 13 + func readTestdata(t *testing.T, name string) []byte { 14 + t.Helper() 15 + b, err := os.ReadFile(filepath.Join("..", "testdata", name)) 16 + if err != nil { 17 + t.Fatalf("expected no error reading testdata/%s, got %s", name, err) 18 + } 19 + return b 20 + } 21 + 22 + func TestParseOGMeta(t *testing.T) { 23 + m := parseOGMeta(readTestdata(t, "card.html")) 24 + if m["og:title"] != "Sample Title" { 25 + t.Errorf("expected og:title to be %q, got %q", "Sample Title", m["og:title"]) 26 + } 27 + if m["og:description"] != "A description" { 28 + t.Errorf("expected og:description to be %q, got %q", "A description", m["og:description"]) 29 + } 30 + } 31 + 32 + func newCardServer(t *testing.T) *httptest.Server { 33 + t.Helper() 34 + thumb := readTestdata(t, "image.jpeg") 35 + html := readTestdata(t, "card.html") 36 + var srv *httptest.Server 37 + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 + if r.URL.Path == "/image.jpeg" { 39 + w.Header().Set("Content-Type", "image/jpeg") 40 + w.Write(thumb) 41 + return 42 + } 43 + w.Header().Set("Content-Type", "text/html") 44 + w.Write([]byte(strings.ReplaceAll(string(html), "SERVER_URL", srv.URL))) 45 + })) 46 + return srv 47 + } 48 + 49 + func newStaticServer(t *testing.T, name string) *httptest.Server { 50 + t.Helper() 51 + b := readTestdata(t, name) 52 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 + w.Header().Set("Content-Type", "text/html") 54 + w.Write(b) 55 + })) 56 + } 57 + 58 + func TestFetchCard(t *testing.T) { 59 + thumb := readTestdata(t, "image.jpeg") 60 + srv := newCardServer(t) 61 + defer srv.Close() 62 + 63 + got := fetchCard(context.Background(), srv.URL+"/page") 64 + if got == nil { 65 + t.Fatal("expected card, got nil") 66 + } 67 + if got.title != "Sample Title" { 68 + t.Errorf("expected title to be %q, got %q", "Sample Title", got.title) 69 + } 70 + if got.description != "A description" { 71 + t.Errorf("expected description to be %q, got %q", "A description", got.description) 72 + } 73 + if got.thumbMime != "image/jpeg" { 74 + t.Errorf("expected thumb mime to be %q, got %q", "image/jpeg", got.thumbMime) 75 + } 76 + if string(got.thumbBytes) != string(thumb) { 77 + t.Errorf("expected thumb bytes to match image.jpeg, got %d bytes", len(got.thumbBytes)) 78 + } 79 + } 80 + 81 + func TestFetchCardMissingTitle(t *testing.T) { 82 + srv := newStaticServer(t, "card_no_title.html") 83 + defer srv.Close() 84 + 85 + if got := fetchCard(context.Background(), srv.URL); got != nil { 86 + t.Errorf("expected nil card when og:title is missing, got %+v", got) 87 + } 88 + } 89 + 90 + func TestFetchCardMissingURL(t *testing.T) { 91 + srv := newStaticServer(t, "card_no_url.html") 92 + defer srv.Close() 93 + 94 + if got := fetchCard(context.Background(), srv.URL); got != nil { 95 + t.Errorf("expected nil card when og:url is missing, got %+v", got) 96 + } 97 + } 98 + 99 + func TestFetchCardHTTPError(t *testing.T) { 100 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 + w.WriteHeader(http.StatusNotFound) 102 + })) 103 + defer srv.Close() 104 + 105 + if got := fetchCard(context.Background(), srv.URL); got != nil { 106 + t.Errorf("expected nil card on HTTP error, got %+v", got) 107 + } 108 + } 109 + 110 + func TestFetchCardNoThumbnail(t *testing.T) { 111 + srv := newStaticServer(t, "card_no_thumbnail.html") 112 + defer srv.Close() 113 + 114 + got := fetchCard(context.Background(), srv.URL) 115 + if got == nil { 116 + t.Fatal("expected card, got nil") 117 + } 118 + if len(got.thumbBytes) != 0 { 119 + t.Errorf("expected no thumb bytes, got %d bytes", len(got.thumbBytes)) 120 + } 121 + } 122 + 123 + func TestFetchCardRelativeImage(t *testing.T) { 124 + thumb := readTestdata(t, "image.jpeg") 125 + html := readTestdata(t, "card_relative_image.html") 126 + var srv *httptest.Server 127 + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 128 + if r.URL.Path == "/image.jpeg" { 129 + w.Header().Set("Content-Type", "image/jpeg") 130 + w.Write(thumb) 131 + return 132 + } 133 + w.Header().Set("Content-Type", "text/html") 134 + w.Write([]byte(strings.ReplaceAll(string(html), "SERVER_URL", srv.URL))) 135 + })) 136 + defer srv.Close() 137 + 138 + got := fetchCard(context.Background(), srv.URL+"/page") 139 + if got == nil { 140 + t.Fatal("expected card, got nil") 141 + } 142 + if string(got.thumbBytes) != string(thumb) { 143 + t.Errorf("expected thumb bytes from relative image URL, got %d bytes", len(got.thumbBytes)) 144 + } 145 + }
+19
client/client.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.org/cuducos.me/not-my-ex/post" 7 + ) 8 + 9 + type Limit struct { 10 + Text int 11 + Image int64 12 + } 13 + 14 + type Client interface { 15 + Post(context.Context, *post.Post) (string, error) 16 + Name() string 17 + Emoji() string 18 + Limit() Limit 19 + }
+215
client/mastodon.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "mime/multipart" 11 + "net/http" 12 + "strings" 13 + "time" 14 + 15 + "golang.org/x/sync/errgroup" 16 + 17 + "tangled.org/cuducos.me/not-my-ex/auth" 18 + "tangled.org/cuducos.me/not-my-ex/post" 19 + ) 20 + 21 + type mastodonMedia struct { 22 + ID string `json:"id"` 23 + } 24 + 25 + type mastodonStatus struct { 26 + URL string `json:"url"` 27 + } 28 + 29 + type mastodonTransport struct { 30 + token string 31 + base http.RoundTripper 32 + } 33 + 34 + func (t *mastodonTransport) RoundTrip(r *http.Request) (*http.Response, error) { 35 + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.token)) 36 + return t.base.RoundTrip(r) 37 + } 38 + 39 + type Mastodon struct { 40 + instance string 41 + client *http.Client 42 + } 43 + 44 + func (*Mastodon) Name() string { return "Mastodon" } 45 + func (*Mastodon) Emoji() string { return "๐Ÿ˜" } 46 + func (*Mastodon) Limit() Limit { return Limit{Text: 1024} } 47 + 48 + func (m *Mastodon) waitMedia(ctx context.Context, id string) error { 49 + url := fmt.Sprintf("%s/api/v1/media/%s", m.instance, id) 50 + wait := 500 * time.Millisecond 51 + for range 42 { 52 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 53 + if err != nil { 54 + return fmt.Errorf("could not create media status request: %w", err) 55 + } 56 + resp, err := m.client.Do(req) 57 + if err != nil { 58 + return fmt.Errorf("could not check media status: %w", err) 59 + } 60 + if err := resp.Body.Close(); err != nil { 61 + slog.Warn("could not close media status response", "error", err) 62 + } 63 + 64 + if resp.StatusCode == http.StatusOK { 65 + return nil 66 + } 67 + 68 + select { 69 + case <-ctx.Done(): 70 + return ctx.Err() 71 + case <-time.After(wait): 72 + } 73 + if wait < 10*time.Second { 74 + wait *= 2 75 + } 76 + } 77 + return fmt.Errorf("could not confirm image status for %s", id) 78 + } 79 + 80 + func (m *Mastodon) uploadMedia(ctx context.Context, media *post.Media) (string, error) { 81 + if _, err := media.Reader.Seek(0, io.SeekStart); err != nil { 82 + return "", fmt.Errorf("could not seek media: %w", err) 83 + } 84 + 85 + var buf bytes.Buffer 86 + w := multipart.NewWriter(&buf) 87 + 88 + n := "image" 89 + if parts := strings.SplitN(media.Mime, "/", 2); len(parts) == 2 { 90 + n = fmt.Sprintf("image.%s", parts[1]) 91 + } 92 + 93 + f, err := w.CreateFormFile("file", n) 94 + if err != nil { 95 + return "", fmt.Errorf("could not create form file: %w", err) 96 + } 97 + if _, err := io.Copy(f, media.Reader); err != nil { 98 + return "", fmt.Errorf("could not read media: %w", err) 99 + } 100 + if media.Alt != "" { 101 + if err := w.WriteField("description", media.Alt); err != nil { 102 + return "", fmt.Errorf("could not write alt text: %w", err) 103 + } 104 + } 105 + if err := w.Close(); err != nil { 106 + return "", fmt.Errorf("could not finalize media upload: %w", err) 107 + } 108 + 109 + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/v2/media", m.instance), &buf) 110 + if err != nil { 111 + return "", fmt.Errorf("could not create media upload request: %w", err) 112 + } 113 + req.Header.Set("Content-Type", w.FormDataContentType()) 114 + 115 + resp, err := m.client.Do(req) 116 + if err != nil { 117 + return "", fmt.Errorf("could not upload media: %w", err) 118 + } 119 + defer func() { 120 + if err := resp.Body.Close(); err != nil { 121 + slog.Warn("could not close response body", "error", err) 122 + } 123 + }() 124 + 125 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { 126 + b, err := io.ReadAll(resp.Body) 127 + if err != nil { 128 + slog.Warn("could not read response body", "error", err) 129 + } 130 + return "", fmt.Errorf("media upload failed with status %d: %s", resp.StatusCode, b) 131 + } 132 + 133 + var out mastodonMedia 134 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 135 + return "", fmt.Errorf("could not parse media upload response: %w", err) 136 + } 137 + 138 + if resp.StatusCode == http.StatusAccepted { 139 + if err := m.waitMedia(ctx, out.ID); err != nil { 140 + return "", err 141 + } 142 + } 143 + 144 + return out.ID, nil 145 + } 146 + 147 + func (m *Mastodon) Post(ctx context.Context, p *post.Post) (string, error) { 148 + d := map[string]any{ 149 + "status": p.Text, 150 + "language": p.Language, 151 + } 152 + 153 + if len(p.Media) > 0 { 154 + ids := make([]string, len(p.Media)) 155 + var g errgroup.Group 156 + for i, media := range p.Media { 157 + g.Go(func() error { 158 + id, err := m.uploadMedia(ctx, media) 159 + if err != nil { 160 + return err 161 + } 162 + ids[i] = id 163 + return nil 164 + }) 165 + } 166 + if err := g.Wait(); err != nil { 167 + return "", err 168 + } 169 + d["media_ids"] = ids 170 + } 171 + 172 + b, err := json.Marshal(d) 173 + if err != nil { 174 + return "", fmt.Errorf("could not marshal post data: %w", err) 175 + } 176 + 177 + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/v1/statuses", m.instance), bytes.NewReader(b)) 178 + if err != nil { 179 + return "", fmt.Errorf("could not create post request: %w", err) 180 + } 181 + req.Header.Set("Content-Type", "application/json") 182 + 183 + resp, err := m.client.Do(req) 184 + if err != nil { 185 + return "", fmt.Errorf("could not post to mastodon: %w", err) 186 + } 187 + defer func() { 188 + if err := resp.Body.Close(); err != nil { 189 + slog.Warn("could not close response body", "error", err) 190 + } 191 + }() 192 + 193 + if resp.StatusCode != http.StatusOK { 194 + b, err := io.ReadAll(resp.Body) 195 + if err != nil { 196 + slog.Warn("could not read response body", "error", err) 197 + } 198 + return "", fmt.Errorf("post failed with status %d: %s", resp.StatusCode, b) 199 + } 200 + 201 + var s mastodonStatus 202 + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { 203 + return "", fmt.Errorf("could not parse post response: %w", err) 204 + } 205 + return s.URL, nil 206 + } 207 + 208 + func NewMastodon(_ context.Context, auth *auth.Mastodon) (*Mastodon, error) { 209 + return &Mastodon{ 210 + instance: auth.Instance, 211 + client: &http.Client{ 212 + Transport: &mastodonTransport{token: *auth.Token, base: http.DefaultTransport}, 213 + }, 214 + }, nil 215 + }
+157
client/mastodon_test.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "io" 9 + "net/http" 10 + "net/http/httptest" 11 + "sync/atomic" 12 + "testing" 13 + 14 + "tangled.org/cuducos.me/not-my-ex/post" 15 + ) 16 + 17 + type testReadSeekCloser struct { 18 + *bytes.Reader 19 + } 20 + 21 + func (r *testReadSeekCloser) Close() error { return nil } 22 + 23 + func newTestMastodon(instance string) *Mastodon { 24 + return &Mastodon{ 25 + instance: instance, 26 + client: &http.Client{ 27 + Transport: &mastodonTransport{token: "test-token", base: http.DefaultTransport}, 28 + }, 29 + } 30 + } 31 + 32 + func newTestMedia(mime, alt string) *post.Media { 33 + return &post.Media{ 34 + Mime: mime, 35 + Alt: alt, 36 + Reader: &testReadSeekCloser{bytes.NewReader([]byte("fake image data"))}, 37 + } 38 + } 39 + 40 + func TestMastodonUploadMedia(t *testing.T) { 41 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 + if r.Method != "POST" || r.URL.Path != "/api/v2/media" { 43 + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) 44 + w.WriteHeader(http.StatusNotFound) 45 + return 46 + } 47 + w.Header().Set("Content-Type", "application/json") 48 + json.NewEncoder(w).Encode(mastodonMedia{ID: "42"}) 49 + })) 50 + defer srv.Close() 51 + 52 + c := newTestMastodon(srv.URL) 53 + m := newTestMedia("image/jpeg", "a test image") 54 + 55 + got, err := c.uploadMedia(context.Background(), m) 56 + if err != nil { 57 + t.Fatalf("expected no error, got %s", err) 58 + } 59 + if got != "42" { 60 + t.Errorf("expected id %q, got %q", "42", got) 61 + } 62 + } 63 + 64 + func TestMastodonUploadMediaServerError(t *testing.T) { 65 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 + w.WriteHeader(http.StatusUnprocessableEntity) 67 + w.Write([]byte("file too large")) 68 + })) 69 + defer srv.Close() 70 + 71 + c := newTestMastodon(srv.URL) 72 + _, err := c.uploadMedia(context.Background(), newTestMedia("image/jpeg", "")) 73 + if err == nil { 74 + t.Error("expected error for server error response, got nil") 75 + } 76 + } 77 + 78 + func TestMastodonUploadMediaAsyncProcessing(t *testing.T) { 79 + var n atomic.Int32 80 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 + if r.Method == "POST" && r.URL.Path == "/api/v2/media" { 82 + w.Header().Set("Content-Type", "application/json") 83 + w.WriteHeader(http.StatusAccepted) 84 + json.NewEncoder(w).Encode(mastodonMedia{ID: "99"}) 85 + return 86 + } 87 + if r.Method == "GET" { 88 + n.Add(1) 89 + w.WriteHeader(http.StatusOK) 90 + return 91 + } 92 + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) 93 + w.WriteHeader(http.StatusNotFound) 94 + })) 95 + defer srv.Close() 96 + 97 + c := newTestMastodon(srv.URL) 98 + got, err := c.uploadMedia(context.Background(), newTestMedia("image/jpeg", "")) 99 + if err != nil { 100 + t.Fatalf("expected no error, got %s", err) 101 + } 102 + if got != "99" { 103 + t.Errorf("expected id %q, got %q", "99", got) 104 + } 105 + if n.Load() != 1 { 106 + t.Errorf("expected 1 poll, got %d", n.Load()) 107 + } 108 + } 109 + 110 + func TestMastodonWaitMediaReady(t *testing.T) { 111 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 + w.WriteHeader(http.StatusOK) 113 + })) 114 + defer srv.Close() 115 + 116 + c := newTestMastodon(srv.URL) 117 + if err := c.waitMedia(context.Background(), "42"); err != nil { 118 + t.Fatalf("expected no error, got %s", err) 119 + } 120 + } 121 + 122 + func TestMastodonWaitMediaContextCancelled(t *testing.T) { 123 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 124 + w.WriteHeader(http.StatusPartialContent) 125 + })) 126 + defer srv.Close() 127 + 128 + c := newTestMastodon(srv.URL) 129 + ctx, cancel := context.WithCancel(context.Background()) 130 + cancel() 131 + 132 + err := c.waitMedia(ctx, "42") 133 + if err == nil { 134 + t.Fatal("expected error for cancelled context, got nil") 135 + } 136 + if !errors.Is(err, context.Canceled) { 137 + t.Errorf("expected context.Canceled in error chain, got %v", err) 138 + } 139 + } 140 + 141 + func TestMastodonUploadMediaAltText(t *testing.T) { 142 + var b []byte 143 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 + b, _ = io.ReadAll(r.Body) 145 + w.Header().Set("Content-Type", "application/json") 146 + json.NewEncoder(w).Encode(mastodonMedia{ID: "1"}) 147 + })) 148 + defer srv.Close() 149 + 150 + c := newTestMastodon(srv.URL) 151 + if _, err := c.uploadMedia(context.Background(), newTestMedia("image/jpeg", "my alt text")); err != nil { 152 + t.Fatalf("expected no error, got %s", err) 153 + } 154 + if !bytes.Contains(b, []byte("my alt text")) { 155 + t.Errorf("expected alt text in request body, got %s", b) 156 + } 157 + }
+140
cmd/cmd.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "os" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/cuducos.me/not-my-ex/auth" 12 + "tangled.org/cuducos.me/not-my-ex/post" 13 + "github.com/spf13/cobra" 14 + "golang.org/x/sync/errgroup" 15 + ) 16 + 17 + var ( 18 + images []string 19 + altTexts []string 20 + lang string 21 + yesToAll bool 22 + skipBluesky bool 23 + skipMastodon bool 24 + editor = os.Getenv("EDITOR") 25 + ) 26 + 27 + var cli = &cobra.Command{ 28 + Use: "not-my-ex", 29 + Short: "Tiny CLI to post simultaneously to Mastodon and Bluesky", 30 + } 31 + 32 + var postCmd = &cobra.Command{ 33 + Use: "post", 34 + Short: "Post content.", 35 + Long: func() string { 36 + if editor == "" { 37 + return "Post content. The text to post can be passed as an optional argument or the path to a text file." 38 + } 39 + return fmt.Sprintf("Post content. The text to post can be passed as an optional argument or the path to a text file; alternatively, opens %s for typing the post content.", editor) 40 + }(), 41 + Args: cobra.MaximumNArgs(1), 42 + RunE: func(cmd *cobra.Command, args []string) error { 43 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) 44 + defer cancel() 45 + 46 + cfg, err := NewConfig(ctx, skipBluesky, skipMastodon) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + txt, err := postContent(args) 52 + if err != nil { 53 + return err 54 + } 55 + if txt == "" && len(images) == 0 { 56 + return errors.New("no content to post") 57 + } 58 + 59 + if strings.TrimSpace(lang) == "" && cfg.Language != nil && *cfg.Language != "" { 60 + lang = *cfg.Language 61 + } 62 + 63 + media, err := post.NewMedias(images, altTexts, cfg.Limit.Image, !yesToAll) 64 + if err != nil { 65 + return err 66 + } 67 + 68 + p, err := post.NewPost(txt, media, lang, cfg.Limit.Text, !yesToAll) 69 + if err != nil { 70 + return err 71 + } 72 + 73 + var g errgroup.Group 74 + for _, c := range cfg.Clients { 75 + g.Go(func() error { 76 + url, err := c.Post(ctx, p) 77 + if err != nil { 78 + return fmt.Errorf("failed to post to %s: %w", strings.ToLower(c.Name()), err) 79 + } 80 + 81 + fmt.Printf("%s %s => %s\n", c.Emoji(), c.Name(), url) 82 + return nil 83 + }) 84 + } 85 + if err := g.Wait(); err != nil { 86 + return err 87 + } 88 + return nil 89 + }, 90 + } 91 + 92 + var cleanCmd = &cobra.Command{ 93 + Use: "clean", 94 + Short: "Delete local authentication credentials and language preferences", 95 + RunE: func(cmd *cobra.Command, args []string) error { 96 + ok, err := auth.Clean() 97 + if err != nil { 98 + return err 99 + } 100 + if ok { 101 + fmt.Println("Credentials deleted.") 102 + } else { 103 + fmt.Println("Nothing to delete.") 104 + } 105 + return nil 106 + }, 107 + } 108 + 109 + var configCmd = &cobra.Command{ 110 + Use: "config", 111 + Short: "Prompt to save authentication credentials and language preferences", 112 + RunE: func(cmd *cobra.Command, args []string) error { 113 + pth, _, err := auth.Path() 114 + if err != nil { 115 + return err 116 + } 117 + if err := auth.Create(pth); err != nil { 118 + return err 119 + } 120 + fmt.Printf("Encrypted credentials saved at %s.\n", pth) 121 + return nil 122 + }, 123 + } 124 + 125 + func init() { 126 + postCmd.Flags().StringArrayVarP(&images, "images", "i", nil, "one to four images to post") 127 + postCmd.Flags().StringArrayVarP(&altTexts, "alt-texts", "a", nil, "one to four alt text for the images") 128 + postCmd.Flags().StringVarP(&lang, "lang", "l", "", "post language (2-letter ISO 639-1 code)") 129 + postCmd.Flags().BoolVarP(&yesToAll, "yes-to-all", "y", false, "Do not ask for alt text for images and/or post language confirmation") 130 + postCmd.Flags().BoolVar(&skipBluesky, "skip-bluesky", false, "Skip posting to Bluesky") 131 + postCmd.Flags().BoolVar(&skipMastodon, "skip-mastodon", false, "Skip posting to Mastodon") 132 + cli.AddCommand(cleanCmd, configCmd, postCmd) 133 + } 134 + 135 + func main() { 136 + if err := cli.Execute(); err != nil { 137 + fmt.Fprintln(os.Stderr, err) 138 + os.Exit(1) 139 + } 140 + }
+83
cmd/config.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "slices" 6 + 7 + "tangled.org/cuducos.me/not-my-ex/auth" 8 + "tangled.org/cuducos.me/not-my-ex/client" 9 + "golang.org/x/sync/errgroup" 10 + ) 11 + 12 + type Config struct { 13 + Clients []client.Client 14 + Limit client.Limit 15 + Language *string 16 + } 17 + 18 + func NewConfig(ctx context.Context, skipBluesky, skipMastodon bool) (*Config, error) { 19 + auth, err := auth.Load(auth.Path) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + cfg := Config{Clients: make([]client.Client, 0, 2), Language: auth.Language} 25 + ch := make(chan client.Client) 26 + done := make(chan struct{}) 27 + go func() { 28 + for c := range ch { 29 + cfg.Clients = append(cfg.Clients, c) 30 + } 31 + done <- struct{}{} 32 + }() 33 + 34 + var g errgroup.Group 35 + if auth.Bluesky != nil && !skipBluesky { 36 + g.Go(func() error { 37 + c, err := client.NewBluesky(ctx, auth.Bluesky) 38 + if err != nil { 39 + return err 40 + } 41 + ch <- c 42 + return nil 43 + }) 44 + } 45 + 46 + if auth.Mastodon != nil && !skipMastodon { 47 + g.Go(func() error { 48 + c, err := client.NewMastodon(ctx, auth.Mastodon) 49 + if err != nil { 50 + return err 51 + } 52 + ch <- c 53 + return nil 54 + }) 55 + } 56 + 57 + if err := g.Wait(); err != nil { 58 + return nil, err 59 + } 60 + close(ch) 61 + <-done 62 + 63 + txts := make([]int, 0, 2) 64 + imgs := make([]int64, 0, 2) 65 + for _, c := range cfg.Clients { 66 + l := c.Limit() 67 + if l.Text > 0 { 68 + txts = append(txts, l.Text) 69 + } 70 + if l.Image > 0 { 71 + imgs = append(imgs, l.Image) 72 + } 73 + } 74 + 75 + if len(txts) > 0 { 76 + cfg.Limit.Text = slices.Min(txts) 77 + } 78 + if len(imgs) > 0 { 79 + cfg.Limit.Image = slices.Min(imgs) 80 + } 81 + 82 + return &cfg, nil 83 + }
+84
cmd/post.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + ) 12 + 13 + func postContentFromFile(pth string) (string, error) { 14 + f, err := os.Open(pth) 15 + if err != nil { 16 + return "", fmt.Errorf("could not open post content from %s: %w", pth, err) 17 + } 18 + defer func() { 19 + if err := f.Close(); err != nil { 20 + slog.Warn("could not properly close file", "path", pth, "error", err) 21 + } 22 + }() 23 + b, err := io.ReadAll(f) 24 + if err != nil { 25 + return "", fmt.Errorf("could not read post content from %s: %w", pth, err) 26 + } 27 + return string(b), nil 28 + } 29 + 30 + func postContentFromArgs(args []string) (string, error) { 31 + if len(args) != 1 { 32 + return "", nil 33 + } 34 + txt, err := postContentFromFile(args[0]) 35 + if err != nil { 36 + if errors.Is(err, os.ErrNotExist) { 37 + return args[0], nil 38 + } 39 + return "", err 40 + } 41 + return txt, nil 42 + } 43 + 44 + func postContentFromEditor() (string, error) { 45 + dir, err := os.MkdirTemp("", "not-my-ex-") 46 + if err != nil { 47 + return "", fmt.Errorf("could not create temporary file for post contents: %w", err) 48 + } 49 + defer func() { 50 + if err := os.RemoveAll(dir); err != nil { 51 + slog.Warn("could not remove temporary directory", "path", dir, "error", err) 52 + } 53 + }() 54 + pth := filepath.Join(dir, "post") 55 + cmd := exec.Command(editor, pth) 56 + cmd.Stderr = os.Stderr 57 + cmd.Stdin = os.Stdin 58 + cmd.Stdout = os.Stdout 59 + if err := cmd.Run(); err != nil { 60 + return "", fmt.Errorf("could not use %s to write post: %w", editor, err) 61 + } 62 + txt, err := postContentFromFile(pth) 63 + if err != nil && !os.IsNotExist(err) { 64 + return "", err 65 + } 66 + return txt, nil 67 + } 68 + 69 + func postContent(args []string) (txt string, err error) { 70 + txt, err = postContentFromArgs(args) 71 + if err != nil { 72 + return "", err 73 + } 74 + if txt != "" { 75 + return txt, nil 76 + } 77 + if editor != "" { 78 + txt, err = postContentFromEditor() 79 + if err != nil { 80 + return "", err 81 + } 82 + } 83 + return txt, nil 84 + }
+57
cmd/post_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestPostContentFromArgsLiteralText(t *testing.T) { 10 + got, err := postContentFromArgs([]string{"hello world"}) 11 + if err != nil { 12 + t.Fatalf("expected no error, got %s", err) 13 + } 14 + if got != "hello world" { 15 + t.Errorf("expected content to be %q, got %q", "hello world", got) 16 + } 17 + } 18 + 19 + func TestPostContentFromArgsFile(t *testing.T) { 20 + f, err := os.CreateTemp(t.TempDir(), "post-*.txt") 21 + if err != nil { 22 + t.Fatalf("expected no error creating temp file, got %s", err) 23 + } 24 + if _, err := f.WriteString("from file"); err != nil { 25 + t.Fatalf("expected no error writing temp file, got %s", err) 26 + } 27 + f.Close() 28 + 29 + got, err := postContentFromArgs([]string{f.Name()}) 30 + if err != nil { 31 + t.Fatalf("expected no error, got %s", err) 32 + } 33 + if got != "from file" { 34 + t.Errorf("expected content to be %q, got %q", "from file", got) 35 + } 36 + } 37 + 38 + func TestPostContentFromArgsNoArgs(t *testing.T) { 39 + got, err := postContentFromArgs([]string{}) 40 + if err != nil { 41 + t.Fatalf("expected no error, got %s", err) 42 + } 43 + if got != "" { 44 + t.Errorf("expected empty content, got %q", got) 45 + } 46 + } 47 + 48 + func TestPostContentFromFileNotExistTreatedAsText(t *testing.T) { 49 + pth := filepath.Join(t.TempDir(), "does-not-exist.txt") 50 + got, err := postContentFromArgs([]string{pth}) 51 + if err != nil { 52 + t.Fatalf("expected no error, got %s", err) 53 + } 54 + if got != pth { 55 + t.Errorf("expected content to be the path %q, got %q", pth, got) 56 + } 57 + }
+51
go.mod
··· 1 + module tangled.org/cuducos.me/not-my-ex 2 + 3 + go 1.26.1 4 + 5 + require ( 6 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd 7 + github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 8 + github.com/gabriel-vasile/mimetype v1.4.13 9 + github.com/nlpodyssey/gopickle v0.3.0 10 + github.com/pemistahl/lingua-go v1.4.0 11 + github.com/spf13/cobra v1.10.2 12 + golang.org/x/sync v0.20.0 13 + golang.org/x/term v0.41.0 14 + ) 15 + 16 + require ( 17 + github.com/beorn7/perks v1.0.1 // indirect 18 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 19 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 20 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 21 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 + github.com/ipfs/go-cid v0.6.0 // indirect 23 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 24 + github.com/minio/sha256-simd v1.0.1 // indirect 25 + github.com/mr-tron/base58 v1.2.0 // indirect 26 + github.com/multiformats/go-base32 v0.1.0 // indirect 27 + github.com/multiformats/go-base36 v0.2.0 // indirect 28 + github.com/multiformats/go-multibase v0.2.0 // indirect 29 + github.com/multiformats/go-multihash v0.2.3 // indirect 30 + github.com/multiformats/go-varint v0.1.0 // indirect 31 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 32 + github.com/prometheus/client_golang v1.23.2 // indirect 33 + github.com/prometheus/client_model v0.6.2 // indirect 34 + github.com/prometheus/common v0.67.5 // indirect 35 + github.com/prometheus/procfs v0.20.1 // indirect 36 + github.com/shopspring/decimal v1.4.0 // indirect 37 + github.com/spaolacci/murmur3 v1.1.0 // indirect 38 + github.com/spf13/pflag v1.0.10 // indirect 39 + github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 40 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 41 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 42 + go.yaml.in/yaml/v2 v2.4.4 // indirect 43 + golang.org/x/crypto v0.49.0 // indirect 44 + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect 45 + golang.org/x/sys v0.42.0 // indirect 46 + golang.org/x/text v0.35.0 // indirect 47 + golang.org/x/time v0.15.0 // indirect 48 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 49 + google.golang.org/protobuf v1.36.11 // indirect 50 + lukechampine.com/blake3 v1.4.1 // indirect 51 + )
+101
go.sum
··· 1 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd h1:FZSMlxClfm7jCA6A/vwTNw5EPxSngPPpK09MxuEx9l0= 4 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 5 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 8 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 11 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 12 + github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM= 13 + github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ= 14 + github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= 15 + github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 16 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 17 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 18 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 19 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 20 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 21 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 22 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 23 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 24 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 25 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 26 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 27 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 28 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 29 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 30 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 31 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 32 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 33 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 34 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 35 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 36 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 37 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 38 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 39 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 40 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 41 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 42 + github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw= 43 + github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= 44 + github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM= 45 + github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME= 46 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 49 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 50 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 51 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 52 + github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= 53 + github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= 54 + github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= 55 + github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 56 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 57 + github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 58 + github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 59 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 60 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 61 + github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 62 + github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 63 + github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 64 + github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 65 + github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 66 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 67 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 68 + github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 69 + github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 70 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 71 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 72 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 73 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 74 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 75 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 76 + go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= 77 + go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 78 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 79 + golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 80 + golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 81 + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= 82 + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= 83 + golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 84 + golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 85 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 86 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 87 + golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= 88 + golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= 89 + golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= 90 + golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 91 + golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= 92 + golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 93 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 94 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 95 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 96 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 97 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 99 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 100 + lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 101 + lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
not_my_ex/__init__.py

This is a binary file and will not be displayed.

-22
not_my_ex/__main__.py
··· 1 - from importlib.metadata import version 2 - 3 - from typer import Typer 4 - 5 - from not_my_ex.cli.config import clean, config 6 - from not_my_ex.cli.post import post 7 - 8 - 9 - def cli(): 10 - app = Typer( 11 - name=f"not-my-ex {version('not_my_ex')}", 12 - help="not-my-ex posts micro blogging to Mastodon and Bluesky.", 13 - ) 14 - 15 - app.command()(post) 16 - app.command()(clean) 17 - app.command()(config) 18 - app() 19 - 20 - 21 - if __name__ == "__main__": 22 - cli()
-192
not_my_ex/auth.py
··· 1 - from base64 import urlsafe_b64encode 2 - from dataclasses import dataclass 3 - from os import getenv, urandom 4 - from pathlib import Path 5 - from pickle import dumps, loads 6 - 7 - from appdirs import user_cache_dir 8 - from cryptography.fernet import Fernet 9 - from cryptography.hazmat.primitives.hashes import SHA512 10 - from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 11 - 12 - from not_my_ex import settings 13 - 14 - SALT_SIZE = 42 15 - 16 - 17 - def cache(): 18 - return Path(user_cache_dir("not-my-ex")) / "auth" 19 - 20 - 21 - @dataclass 22 - class BlueskyAuth: 23 - agent: str 24 - email: str 25 - password: str 26 - 27 - 28 - @dataclass 29 - class MastodonAuth: 30 - instance: str 31 - token: str 32 - 33 - 34 - @dataclass 35 - class AuthData: 36 - bluesky: BlueskyAuth | None = None 37 - mastodon: MastodonAuth | None = None 38 - language: str | None = None 39 - 40 - 41 - class EnvironmentVariableNotFoundError(Exception): ... 42 - 43 - 44 - class Auth: 45 - def __init__(self): 46 - self.data = AuthData() 47 - 48 - def for_client(self, key): 49 - if key == settings.BLUESKY: 50 - return self.bluesky 51 - if key == settings.MASTODON: 52 - return self.mastodon 53 - raise ValueError("Invalid client") 54 - 55 - def invalidate(self, key): 56 - if key == settings.BLUESKY: 57 - self.data.bluesky = None 58 - if key == settings.MASTODON: 59 - self.data.mastodon = None 60 - 61 - @property 62 - def bluesky(self) -> BlueskyAuth | None: 63 - return self.data.bluesky 64 - 65 - @property 66 - def mastodon(self) -> MastodonAuth | None: 67 - return self.data.mastodon 68 - 69 - @property 70 - def language(self) -> str | None: 71 - return self.data.language 72 - 73 - @property 74 - def clients(self): 75 - clients = ( 76 - (settings.BLUESKY, self.bluesky is not None), 77 - (settings.MASTODON, self.mastodon is not None), 78 - ) 79 - return tuple(key for key, configured in clients if configured) 80 - 81 - def assure_configured(self): 82 - if not self.clients: 83 - raise EnvironmentVariableNotFoundError( 84 - "No clients available. Please set at least one of the following " 85 - "environment variables (or run `not-my-ex config`):\n" 86 - "- NOT_MY_EX_BSKY_EMAIL and NOT_MY_EX_BSKY_PASSWORD" 87 - "- NOT_MY_EX_MASTODON_TOKEN" 88 - ) 89 - 90 - @property 91 - def limit(self): 92 - if self.bluesky: 93 - return 300 94 - return 1024 95 - 96 - @property 97 - def image_size_limit(self): 98 - if self.bluesky: 99 - return 1024 * 1024 100 - 101 - 102 - class FernetWithPassword(Fernet): 103 - def __init__(self, password: bytes, salt: bytes) -> None: 104 - kdf = PBKDF2HMAC(algorithm=SHA512(), length=32, salt=salt, iterations=2**19) 105 - key = urlsafe_b64encode(kdf.derive(password)) 106 - super().__init__(key) 107 - 108 - 109 - class EncryptedAuth(Auth): 110 - """This class manages authentication information (API tokens, logins, etc.) stored 111 - locally in an encrypted password-protected file.""" 112 - 113 - def __init__(self, password: str) -> None: 114 - self.path = cache() 115 - if self.path.exists(): 116 - with self.path.open("rb") as cursor: 117 - self.salt = cursor.read(SALT_SIZE) 118 - encrypted = cursor.read() 119 - self.algorithm = FernetWithPassword(password.encode("utf-8"), self.salt) 120 - self.data = loads(self.algorithm.decrypt(encrypted)) 121 - else: 122 - self.salt = urandom(SALT_SIZE) 123 - self.algorithm = FernetWithPassword(password.encode("utf-8"), self.salt) 124 - self.data = AuthData() 125 - 126 - def persist(self, data: BlueskyAuth | MastodonAuth | str) -> None: 127 - if not self.path.exists(): 128 - self.path.parent.mkdir(exist_ok=True) 129 - 130 - if isinstance(data, BlueskyAuth): 131 - self.data.bluesky = data 132 - if isinstance(data, MastodonAuth): 133 - self.data.mastodon = data 134 - if isinstance(data, str): 135 - self.data.language = data 136 - 137 - with self.path.open("wb") as cursor: 138 - cursor.write(self.salt) 139 - cursor.write(self.algorithm.encrypt(dumps(self.data))) 140 - 141 - def save_bluesky(self, email: str, password: str, agent: str | None = None) -> None: 142 - assert email, "Email cannot be empty" 143 - assert password, "Password cannot be empty" 144 - self.persist( 145 - BlueskyAuth( 146 - agent or settings.DEFAULT_BLUESKY_AGENT, 147 - email, 148 - password, 149 - ) 150 - ) 151 - 152 - def save_mastodon(self, token: str, instance: str | None = None) -> None: 153 - assert token, "Token cannot be empty" 154 - self.persist( 155 - MastodonAuth( 156 - instance or settings.DEFAULT_MASTODON_INSTANCE, 157 - token, 158 - ) 159 - ) 160 - 161 - def save_language(self, language: str) -> None: 162 - self.persist(language) 163 - 164 - 165 - class EnvAuth(Auth): 166 - """This class offers the same `Auth` API but loading credentials from environment 167 - variables instead.""" 168 - 169 - def __init__(self) -> None: 170 - self.data = AuthData() 171 - self.data.language = getenv("NOT_MY_EX_DEFAULT_LANG") 172 - 173 - bluesky_agent = getenv("NOT_MY_EX_BSKY_AGENT", settings.DEFAULT_BLUESKY_AGENT) 174 - bluesky_email = getenv("NOT_MY_EX_BSKY_EMAIL", "") 175 - bluesky_password = getenv("NOT_MY_EX_BSKY_PASSWORD", "") 176 - if all((bluesky_agent, bluesky_email, bluesky_password)): 177 - self.data.bluesky = BlueskyAuth( 178 - bluesky_agent, bluesky_email, bluesky_password 179 - ) 180 - 181 - mastodon_token = getenv("NOT_MY_EX_MASTODON_TOKEN", "") 182 - mastodon_instance = getenv( 183 - "NOT_MY_EX_MASTODON_INSTANCE", settings.DEFAULT_MASTODON_INSTANCE 184 - ) 185 - if all((mastodon_instance, mastodon_token)): 186 - self.data.mastodon = MastodonAuth(mastodon_instance, mastodon_token) 187 - 188 - 189 - def authenticate(password: str | None = None) -> Auth: 190 - if password and cache().exists(): 191 - return EncryptedAuth(password) 192 - return EnvAuth()
-201
not_my_ex/bluesky.py
··· 1 - from asyncio import gather 2 - from datetime import datetime, timezone 3 - from re import compile 4 - from string import punctuation 5 - 6 - from backoff import expo, on_exception 7 - from httpx import AsyncClient, ReadTimeout, Response 8 - 9 - from not_my_ex.auth import BlueskyAuth 10 - from not_my_ex.card import Card 11 - from not_my_ex.client import Client 12 - from not_my_ex.media import Media 13 - from not_my_ex.post import Post 14 - 15 - PUNCTUATION = set(punctuation) 16 - HASHTAG = compile(r"#\S+") 17 - URL = compile( 18 - r"(http(s?):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-]))" 19 - ) 20 - 21 - 22 - class BlueskyCredentialsNotFoundError(Exception): 23 - pass 24 - 25 - 26 - class Bluesky(Client): 27 - def __init__(self, client: AsyncClient, auth: BlueskyAuth) -> None: 28 - self.agent = auth.agent 29 - self.credentials = {"identifier": auth.email, "password": auth.password} 30 - self.token, self.did, self.handle = None, None, None 31 - self.is_authenticated = False 32 - super().__init__(client) 33 - 34 - async def auth(self) -> None: 35 - resp = await self.client.post( 36 - f"{self.agent}/xrpc/com.atproto.server.createSession", 37 - json=self.credentials, 38 - ) 39 - if resp.status_code != 200: 40 - self.raise_from(resp) 41 - 42 - data = resp.json() 43 - self.token, self.did, self.handle = ( 44 - data["accessJwt"], 45 - data["did"], 46 - data["handle"], 47 - ) 48 - self.is_authenticated = True 49 - 50 - @on_exception(expo, ReadTimeout, max_tries=7) 51 - async def xrpc(self, resource: str, **kwargs) -> Response: 52 - headers = kwargs.pop("headers", {}) 53 - headers["Authorization"] = f"Bearer {self.token}" 54 - url = f"{self.agent}/xrpc/{resource}" 55 - return await self.client.post(url, headers=headers, **kwargs) 56 - 57 - async def upload(self, media: Media) -> dict: 58 - if not self.is_authenticated: 59 - await self.auth() 60 - 61 - resp, dimensions = await gather( 62 - self.xrpc( 63 - "com.atproto.repo.uploadBlob", 64 - headers={"Content-type": media.mime}, 65 - data=media.content, 66 - ), 67 - media.dimensions(), 68 - ) 69 - if resp.status_code != 200: 70 - self.raise_from(resp) 71 - 72 - embed = {"alt": media.alt or "", "image": resp.json().get("blob")} 73 - if dimensions: 74 - width, height = dimensions 75 - embed["aspectRatio"] = { 76 - "$type": "app.bsky.embed.defs", 77 - "width": width, 78 - "height": height, 79 - } 80 - 81 - return embed 82 - 83 - async def data(self, post): 84 - if not self.is_authenticated: 85 - await self.auth() 86 - 87 - created_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() 88 - data = { 89 - "repo": self.did, 90 - "collection": "app.bsky.feed.post", 91 - "record": { 92 - "$type": "app.bsky.feed.post", 93 - "text": post.text, 94 - "createdAt": created_at, 95 - "langs": [post.lang], 96 - }, 97 - } 98 - 99 - first_link = None 100 - if matches := URL.findall(post.text): 101 - data["record"]["facets"] = data["record"].get("facets", []) 102 - start = 0 103 - source = post.text.encode() 104 - for url, *_ in matches: 105 - if not first_link: 106 - first_link = url 107 - 108 - target = url.encode() 109 - start = source.find(target, start) 110 - end = start + len(target) 111 - data["record"]["facets"].append( 112 - { 113 - "index": {"byteStart": start, "byteEnd": end}, 114 - "features": [ 115 - { 116 - "$type": "app.bsky.richtext.facet#link", 117 - "uri": url, 118 - } 119 - ], 120 - } 121 - ) 122 - start = end 123 - 124 - url_intervals = tuple( 125 - (url["index"]["byteStart"], url["index"]["byteEnd"]) 126 - for url in data["record"].get("facets", []) 127 - ) 128 - 129 - def is_inside_url(pos): 130 - return any(start < pos < end for start, end in url_intervals) 131 - 132 - if matches := HASHTAG.findall(post.text): 133 - data["record"]["facets"] = data["record"].get("facets", []) 134 - start = 0 135 - source = post.text.encode() 136 - for hashtag in matches: 137 - if hashtag.removeprefix("#").isdigit(): 138 - continue 139 - while hashtag[-1:] in PUNCTUATION: 140 - hashtag = hashtag[:-1] 141 - if len(hashtag) < 2: 142 - continue 143 - target = hashtag.encode() 144 - start = source.find(target, start) 145 - if is_inside_url(start): 146 - continue 147 - end = start + len(target) 148 - data["record"]["facets"].append( 149 - { 150 - "index": {"byteStart": start, "byteEnd": end}, 151 - "features": [ 152 - { 153 - "$type": "app.bsky.richtext.facet#tag", 154 - "tag": hashtag.removeprefix("#"), 155 - } 156 - ], 157 - } 158 - ) 159 - start = end 160 - 161 - if post.media: 162 - uploads = tuple(self.upload(media) for media in post.media) 163 - embed = await gather(*uploads) 164 - data["record"]["embed"] = { 165 - "$type": "app.bsky.embed.images", 166 - "images": embed, 167 - } 168 - elif first_link: 169 - card = await Card.from_url(first_link) 170 - if card: 171 - embed = { 172 - "$type": "app.bsky.embed.external", 173 - "external": { 174 - "uri": card.uri, 175 - "title": card.title, 176 - }, 177 - } 178 - if card.description: 179 - embed["external"]["description"] = card.description 180 - if card.media: 181 - uploaded = await self.upload(card.media) 182 - embed["external"]["thumb"] = uploaded["image"] 183 - data["record"]["embed"] = embed 184 - 185 - return data 186 - 187 - async def url_from(self, resp: Response) -> str: 188 - data = resp.json() 189 - *_, post_id = data["uri"].split("/") 190 - return f"https://bsky.app/profile/{self.handle}/post/{post_id}" 191 - 192 - async def post(self, post: Post) -> str: 193 - if not self.is_authenticated: 194 - await self.auth() 195 - 196 - data = await self.data(post) 197 - resp = await self.xrpc("com.atproto.repo.createRecord", json=data) 198 - if resp.status_code != 200: 199 - self.raise_from(resp) 200 - 201 - return await self.url_from(resp)
-73
not_my_ex/card.py
··· 1 - from dataclasses import dataclass 2 - 3 - from bs4 import BeautifulSoup, Tag 4 - from httpx import AsyncClient, HTTPStatusError, RequestError 5 - 6 - from not_my_ex.media import Media 7 - from not_my_ex.mime import mime_for 8 - 9 - 10 - async def request_bytes(client: AsyncClient, url: str) -> bytes | None: 11 - try: 12 - response = await client.get(url) 13 - except (HTTPStatusError, RequestError): 14 - return None 15 - if response.status_code != 200: 16 - return None 17 - return response.content 18 - 19 - 20 - def meta(soup: BeautifulSoup, prop: str) -> str | None: 21 - tag = soup.find("meta", property=prop) 22 - if isinstance(tag, Tag) and tag.has_attr("content"): 23 - return str(tag["content"]) 24 - return None 25 - 26 - 27 - @dataclass 28 - class Card: 29 - uri: str 30 - title: str 31 - description: str | None 32 - thumb: bytes | None 33 - mime: str | None 34 - 35 - @property 36 - def media(self): 37 - if not self.thumb or not self.mime: 38 - return None 39 - return Media(None, self.thumb, self.mime) 40 - 41 - @classmethod 42 - async def from_url(cls, url: str) -> "Card | None": 43 - async with AsyncClient() as client: 44 - html = await request_bytes(client, url) 45 - if html is None: 46 - return None 47 - 48 - soup = BeautifulSoup(html.decode("utf-8"), "html.parser") 49 - title = meta(soup, "og:title") 50 - if not title: 51 - return None 52 - 53 - uri = meta(soup, "og:url") 54 - if not uri: 55 - return None 56 - 57 - description = meta(soup, "og:description") 58 - thumb_url = meta(soup, "og:image") 59 - if not thumb_url: 60 - return Card(uri, title, description, None, None) 61 - 62 - if "://" not in thumb_url: 63 - thumb_url = f"{url}{thumb_url}" 64 - 65 - thumb = await request_bytes(client, thumb_url) 66 - if not thumb: 67 - return Card(uri, title, description, None, None) 68 - 69 - mime = mime_for(thumb_url, thumb) 70 - if not mime: 71 - return Card(uri, title, description, None, None) 72 - 73 - return Card(url, title, description, thumb, mime)
-18
not_my_ex/cli/__init__.py
··· 1 - from sys import stderr 2 - 3 - from typer import colors, echo, style 4 - 5 - 6 - def error(err: Exception | str, details: str | None = None): 7 - if isinstance(err, Exception): 8 - title = err.__class__.__name__ 9 - details = str(err) 10 - else: 11 - title = err 12 - 13 - msg = style(title, bold=True, fg=colors.RED) 14 - if details: 15 - msg += details 16 - 17 - echo(msg, file=stderr) 18 - exit(1)
-79
not_my_ex/cli/config.py
··· 1 - from os import getenv 2 - 3 - from typer import colors, confirm, echo, prompt, secho, style 4 - 5 - from not_my_ex.auth import EncryptedAuth, cache 6 - from not_my_ex.settings import DEFAULT_BLUESKY_AGENT, DEFAULT_MASTODON_INSTANCE 7 - 8 - 9 - def clean() -> None: 10 - """Delete local authentication credentials and language preferences.""" 11 - path = cache() 12 - if not path.exists(): 13 - secho( 14 - f"{path} not found, nothing to delete.", 15 - fg=colors.YELLOW, 16 - bold=True, 17 - ) 18 - return 19 - 20 - confirm(f"Do you want to delete {path}?", abort=True) 21 - path.unlink() 22 - secho(f"{path} deleted", fg=colors.GREEN, bold=True) 23 - 24 - 25 - def config() -> None: 26 - """Prompt to save authentication credentials and language preferences.""" 27 - path = cache() 28 - not_my_ex = style("not-my-ex", bold=True) 29 - discouraged = style("highly discouraged", bold=True) 30 - 31 - if path.exists(): 32 - password = prompt( 33 - f"Please, enter the password you used to configure {not_my_ex}", 34 - hide_input=True, 35 - ) 36 - else: 37 - echo( 38 - "Create a password to protect the local credentials you are gonne use " 39 - f"configure {not_my_ex}. An empty password is allowed but {discouraged}." 40 - ) 41 - password = prompt("Password", hide_input=True, confirmation_prompt=True) 42 - 43 - auth = EncryptedAuth(password) 44 - 45 - if confirm("Do you want to configure Bluesky?"): 46 - email = prompt("Email you used to create your Bkuesky account") 47 - app_password = prompt( 48 - ( 49 - f"App password you created for {not_my_ex} " 50 - "(see https://bsky.app/settings/app-passwords)" 51 - ), 52 - hide_input=True, 53 - ) 54 - echo("Bluesky requires a custom agent name. ") 55 - agent = prompt("Bluesky agent", default=DEFAULT_BLUESKY_AGENT) 56 - auth.save_bluesky(email, app_password, agent) 57 - 58 - if confirm("Do you want to configure Mastodon?"): 59 - settings = style("Settings", bold=True) 60 - development = style("Development", bold=True) 61 - write = " and ".join( 62 - style(txt, bold=True) for txt in ("write:statuses", "write:media") 63 - ) 64 - token = prompt( 65 - ( 66 - f"Token for {not_my_ex} (visit {settings} ยป {development} and " 67 - f"create one with {write} permissions)" 68 - ), 69 - hide_input=True, 70 - ) 71 - instance = prompt("Mastodon instance", default=DEFAULT_MASTODON_INSTANCE) 72 - auth.save_mastodon(token, instance) 73 - 74 - language = prompt( 75 - "Preferred language for posts (2-letter ISO 639-1 code)", 76 - default=getenv("NOT_MY_EX_DEFAULT_LANG"), 77 - ) 78 - auth.save_language(language) 79 - secho(f"Saved encrypted configuration at {path}", fg=colors.GREEN, bold=True)
-163
not_my_ex/cli/post.py
··· 1 - from asyncio import gather, run 2 - from collections import namedtuple 3 - from itertools import zip_longest 4 - from os import getenv 5 - from pathlib import Path 6 - from subprocess import call 7 - from sys import stderr 8 - from tempfile import NamedTemporaryFile 9 - from typing import Annotated 10 - 11 - from aiofiles import open, os 12 - from httpx import AsyncClient 13 - from typer import Argument, Option, echo, prompt, style 14 - 15 - from not_my_ex.auth import Auth, authenticate, cache 16 - from not_my_ex.bluesky import Bluesky 17 - from not_my_ex.cli import error 18 - from not_my_ex.client import ClientError 19 - from not_my_ex.mastodon import Mastodon 20 - from not_my_ex.media import ImageTooBigError, Media 21 - from not_my_ex.post import Post, PostTooLongError 22 - from not_my_ex.settings import BLUESKY, MASTODON 23 - 24 - CLIENTS = {BLUESKY: Bluesky, MASTODON: Mastodon} 25 - 26 - Sent = namedtuple("Sent", "url client emoji") 27 - 28 - 29 - async def send(key: str, http: AsyncClient, auth: Auth, post: Post) -> Sent | None: 30 - try: 31 - cls = CLIENTS[key] 32 - except KeyError: 33 - raise ValueError(f"Unknown client {key}, options are: {', '.join(CLIENTS)}") 34 - 35 - client = cls(http, auth.for_client(key)) 36 - try: 37 - url = await client.post(post) 38 - except ClientError as exc: 39 - print(str(exc), file=stderr) 40 - return None 41 - 42 - return Sent(url, client.name, client.emoji) 43 - 44 - 45 - async def media_from( 46 - auth: Auth, path: str, alt_text: str | None, ask_for_alt_text: bool 47 - ) -> Media: 48 - media = await Media.from_img(path, alt_text, auth.image_size_limit) 49 - if ask_for_alt_text: 50 - media.check_alt_text() 51 - return media 52 - 53 - 54 - async def main( 55 - auth: Auth, 56 - text: str, 57 - images: list[str], 58 - alt_texts: list[str], 59 - lang: str | None, 60 - yes_to_all: bool, 61 - ) -> None: 62 - if len(images) > 4: 63 - error("You can only post up to 4 images") 64 - 65 - if len(alt_texts) > len(images): 66 - error(f"Got {len(alt_texts)} alt texts, but only {len(images)}") 67 - 68 - if await os.path.exists(text): 69 - async with open(text) as handler: 70 - text = await handler.read() 71 - 72 - load = tuple( 73 - media_from(auth, path, alt_text, not yes_to_all) 74 - for path, alt_text in zip_longest(images, alt_texts) 75 - ) 76 - try: 77 - imgs = await gather(*load) 78 - except ImageTooBigError as err: 79 - error(err) 80 - 81 - post = Post(text, auth.limit, imgs or None, lang) 82 - if post.is_empty(): 83 - error("No text (or image) to post") 84 - 85 - if not lang and not yes_to_all: 86 - post.check_language() 87 - 88 - async with AsyncClient() as http: 89 - tasks = tuple(send(key, http, auth, post) for key in auth.clients) 90 - posts = await gather(*tasks) 91 - 92 - for sent in posts: 93 - if sent: 94 - echo(f"{sent.emoji} {style(sent.client, bold=True)} {sent.url}") 95 - 96 - 97 - def editor(): 98 - with NamedTemporaryFile(prefix="not-my-ex-post", suffix=".txt") as tmp: 99 - call((getenv("EDITOR", "vim"), tmp.name)) 100 - text = Path(tmp.name).read_text().strip() 101 - return text 102 - 103 - 104 - def post( 105 - text: str = Argument( 106 - None, 107 - help=( 108 - "Text to post or path to a text file containing the content to post. " 109 - "If left blank, an editor (`EDITOR` environment variable) will open " 110 - "for you to type the post." 111 - ), 112 - ), 113 - images: Annotated[ 114 - list[str], Option("--images", "-i", help="Path to the images to post (max. 4)") 115 - ] = [], 116 - alt_texts: Annotated[ 117 - list[str], 118 - Option("--alt-texts", "-a", help="Alt text for --images (same order)"), 119 - ] = [], 120 - lang: Annotated[ 121 - str | None, 122 - Option( 123 - "--lang", 124 - "-l", 125 - help="Language for the post (2-letter ISO 639-1 code)", 126 - ), 127 - ] = None, 128 - yes_to_all: Annotated[ 129 - bool, 130 - Option( 131 - "--yes-to-all", 132 - "-y", 133 - help="Do not ask for alt text for images and/or post language confirmation", 134 - ), 135 - ] = False, 136 - skip_bluesky: Annotated[ 137 - bool, Option("--skip-bluesky", help="Skip posting to Bluesky") 138 - ] = False, 139 - skip_mastodon: Annotated[ 140 - bool, Option("--skip-mastodon", help="Skip posting to Mastodon") 141 - ] = False, 142 - ) -> None: 143 - """Post content. TEXT can be the post text itself, or the path to a text file.""" 144 - password = None 145 - if cache().exists(): 146 - not_my_ex = style("not-my-ex", bold=True) 147 - password = prompt( 148 - f"Please, enter the password you used to configure {not_my_ex}", 149 - hide_input=True, 150 - ) 151 - 152 - auth = authenticate(password) 153 - if skip_bluesky: 154 - auth.invalidate(BLUESKY) 155 - if skip_mastodon: 156 - auth.invalidate(MASTODON) 157 - auth.assure_configured() 158 - 159 - text = text or editor() 160 - try: 161 - run(main(auth, text, images, alt_texts, lang, yes_to_all)) 162 - except PostTooLongError as err: 163 - error(err)
-46
not_my_ex/client.py
··· 1 - from abc import ABC, abstractmethod 2 - 3 - from httpx import Response 4 - 5 - from not_my_ex.media import Media 6 - from not_my_ex.post import Post 7 - 8 - 9 - class ClientError(Exception): 10 - pass 11 - 12 - 13 - class Client(ABC): 14 - def __init__(self, client) -> None: 15 - self.client = client 16 - 17 - @property 18 - def name(self): 19 - return self.__class__.__name__.split(".")[-1] 20 - 21 - @property 22 - def emoji(self): 23 - if self.name == "Bluesky": 24 - return "๐Ÿฆ‹" 25 - if self.name == "Mastodon": 26 - return "๐Ÿ˜" 27 - 28 - def raise_from(self, response: Response) -> None: 29 - data = response.json() 30 - if "message" in data: 31 - details = f"{data['error']}: {data['message']}" 32 - else: 33 - details = data["error"] 34 - msg = ( 35 - f"Error from {self.name} server - {response.url} " 36 - f"HTTP Status {response.status_code} - {details}" 37 - ) 38 - raise ClientError(msg) 39 - 40 - @abstractmethod 41 - async def upload(self, media: Media) -> str | dict: 42 - pass 43 - 44 - @abstractmethod 45 - async def post(self, post: Post) -> str: 46 - pass
-27
not_my_ex/language.py
··· 1 - from dataclasses import dataclass 2 - 3 - 4 - @dataclass 5 - class Language: 6 - name: str | None = None 7 - 8 - def __post_init__(self) -> None: 9 - self.clean() 10 - 11 - def clean(self) -> None: 12 - if self.name: 13 - self.name = self.name.strip().lower() or None 14 - 15 - def is_valid(self) -> bool: 16 - if not self.name: 17 - return False 18 - 19 - return len(self.name) == 2 and self.name.isalpha() 20 - 21 - def ask(self) -> None: 22 - self.name = None 23 - while not self.name: 24 - self.name = input("Enter the language (2-letter ISO 639-1 code): ") 25 - self.clean() 26 - if not self.is_valid(): 27 - self.name = None
-70
not_my_ex/mastodon.py
··· 1 - from asyncio import gather 2 - from io import BytesIO 3 - 4 - from backoff import expo, on_exception 5 - from httpx import AsyncClient, Response 6 - 7 - from not_my_ex.auth import MastodonAuth 8 - from not_my_ex.client import Client 9 - from not_my_ex.media import Media 10 - from not_my_ex.post import Post 11 - 12 - 13 - class MediaNotReadyError(Exception): 14 - pass 15 - 16 - 17 - class MastodonCredentialsNotFoundError(Exception): 18 - pass 19 - 20 - 21 - class Mastodon(Client): 22 - def __init__(self, client: AsyncClient, auth: MastodonAuth) -> None: 23 - self.instance = auth.instance 24 - self.headers = {"Authorization": f"Bearer {auth.token}"} 25 - super().__init__(client) 26 - 27 - async def request(self, path: str, **kwargs) -> Response: 28 - return await self.client.post( 29 - f"{self.instance}{path}", headers=self.headers, **kwargs 30 - ) 31 - 32 - @on_exception(expo, MediaNotReadyError, max_tries=42) 33 - async def wait_media_processing(self, media_id) -> None: 34 - resp = await self.client.get( 35 - f"{self.instance}/api/v1/media/{media_id}", 36 - headers=self.headers, 37 - ) 38 - if resp.status_code != 200: 39 - raise MediaNotReadyError(resp) 40 - 41 - async def upload(self, media: Media) -> str: 42 - data = {"description": media.alt} if media.alt else None 43 - ext = media.mime.split("/")[1] 44 - 45 - with BytesIO(media.content) as attachment: 46 - files = {"file": (f"image.{ext}", attachment, media.mime)} 47 - resp = await self.request("/api/v2/media", data=data, files=files) 48 - 49 - if resp.status_code not in (202, 200): 50 - self.raise_from(resp) 51 - 52 - data = resp.json() or {} 53 - media_id = str(data["id"]) 54 - if resp.status_code == 202: 55 - await self.wait_media_processing(media_id) 56 - 57 - return media_id 58 - 59 - async def post(self, post: Post) -> str: 60 - data = {"status": post.text, "language": post.lang} 61 - if post.media: 62 - uploads = tuple(self.upload(media) for media in post.media) 63 - data["media_ids"] = await gather(*uploads) # type: ignore 64 - 65 - resp = await self.request("/api/v1/statuses", json=data) 66 - if resp.status_code != 200: 67 - self.raise_from(resp) 68 - 69 - data = resp.json() 70 - return str(data["url"])
-97
not_my_ex/media.py
··· 1 - from dataclasses import dataclass 2 - from pathlib import Path 3 - 4 - from aiofiles import open, os 5 - 6 - from not_my_ex.mime import mime_for 7 - 8 - 9 - class ImageTooBigError(Exception): 10 - pass 11 - 12 - 13 - @dataclass 14 - class Media: 15 - path: str | None 16 - content: bytes 17 - mime: str 18 - alt: str | None = None 19 - 20 - @classmethod 21 - async def from_img( 22 - cls, img: str, alt: str | None = None, limit: int | None = None 23 - ) -> "Media": 24 - if not await os.path.exists(img): 25 - raise ValueError(f"File {img} does not exist") 26 - 27 - if limit and Path(img).stat().st_size > limit: 28 - raise ImageTooBigError(f"{img} is larger than {limit} bytes") 29 - 30 - async with open(img, "rb") as handler: 31 - contents = await handler.read() 32 - 33 - mime = mime_for(img, contents) 34 - if not isinstance(mime, str): 35 - raise ValueError(f"Could not guess mime type for {img}") 36 - 37 - return cls(img, contents, mime, alt) 38 - 39 - def check_alt_text(self): 40 - while self.path and not self.alt: 41 - alt = input(f"Enter an alt text for {self.path}: ") 42 - self.alt = alt.strip() or None 43 - 44 - async def dimensions(self) -> tuple[int, int] | None: 45 - if not self.path: 46 - return None 47 - 48 - if self.mime == "image/png": 49 - async with open(self.path, "rb") as reader: 50 - await reader.seek(16) 51 - width_bytes = await reader.read(4) 52 - height_bytes = await reader.read(4) 53 - if len(width_bytes) == 4 and len(height_bytes) == 4: 54 - width = int.from_bytes(width_bytes, "big") 55 - height = int.from_bytes(height_bytes, "big") 56 - return width, height 57 - 58 - if self.mime == "image/jpeg": 59 - async with open(self.path, "rb") as reader: 60 - while True: 61 - marker = await reader.read(2) 62 - if not marker or len(marker) < 2: 63 - break 64 - 65 - if marker == b"\xff\xd8": 66 - continue 67 - 68 - if marker in (b"\xff\xc0", b"\xff\xc2"): 69 - length_bytes = await reader.read(2) 70 - if not length_bytes or len(length_bytes) < 2: 71 - break 72 - 73 - length = int.from_bytes(length_bytes, "big") - 2 74 - if length < 0: 75 - break 76 - 77 - await reader.read(1) 78 - height_bytes = await reader.read(2) 79 - width_bytes = await reader.read(2) 80 - if len(height_bytes) == 2 and len(width_bytes) == 2: 81 - height = int.from_bytes(height_bytes, "big") 82 - width = int.from_bytes(width_bytes, "big") 83 - return width, height 84 - break 85 - 86 - else: 87 - length_bytes = await reader.read(2) 88 - if not length_bytes or len(length_bytes) < 2: 89 - break 90 - 91 - length = int.from_bytes(length_bytes, "big") - 2 92 - if length < 0: 93 - break 94 - 95 - await reader.seek(length, 1) 96 - 97 - return None
-18
not_my_ex/mime.py
··· 1 - from mimetypes import guess_type 2 - 3 - GUESSES = {"image/jpeg": ("jpg", "jpeg"), "image/png": ("png",)} 4 - 5 - 6 - def mime_for(path: str, contents: bytes) -> str | None: 7 - mime, *_ = guess_type(path) 8 - if isinstance(mime, str): 9 - return mime 10 - 11 - for mime, guesses in GUESSES.items(): 12 - for guess in guesses: 13 - if guess.upper().encode() in contents[:128]: 14 - return mime 15 - if guess.encode() in contents[:128]: 16 - return mime 17 - 18 - return None
-47
not_my_ex/post.py
··· 1 - from collections.abc import Sequence 2 - from dataclasses import dataclass 3 - from pathlib import Path 4 - from tempfile import NamedTemporaryFile 5 - 6 - from eld import LanguageDetector # type: ignore 7 - 8 - from not_my_ex.language import Language 9 - from not_my_ex.media import Media 10 - 11 - 12 - class PostTooLongError(Exception): ... 13 - 14 - 15 - @dataclass 16 - class Post: 17 - text: str 18 - limit: int = 300 19 - media: Sequence[Media] | None = None 20 - lang: str | None = None 21 - 22 - def __post_init__(self): 23 - if len(self.text) > self.limit: 24 - with NamedTemporaryFile(delete=False) as tmp: 25 - Path(tmp.name).write_text(self.text) 26 - raise PostTooLongError( 27 - f"Text cannot be longer than {self.limit:,} characters. This text " 28 - f"is {len(self.text):,} characters long. If you need to recover " 29 - f"your draft, it is saved at: {tmp.name}." 30 - ) 31 - 32 - if not self.lang: 33 - detector = LanguageDetector() 34 - self.lang = detector.detect(self.text).language 35 - 36 - def check_language(self): 37 - if self.lang: 38 - answer = input(f"Is the post language {self.lang}? [y/n] ") 39 - if answer.lower() == "y": 40 - return 41 - 42 - lang = Language() 43 - lang.ask() 44 - self.lang = lang.name 45 - 46 - def is_empty(self) -> bool: 47 - return not bool(self.text or self.media)
-5
not_my_ex/settings.py
··· 1 - BLUESKY = "bsky" 2 - MASTODON = "mstdn" 3 - 4 - DEFAULT_BLUESKY_AGENT = "https://bsky.social" 5 - DEFAULT_MASTODON_INSTANCE = "https://mastodon.social"
+57
post/language.go
··· 1 + package post 2 + 3 + import ( 4 + "bufio" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "strings" 10 + ) 11 + 12 + func newLanguage(name string) (string, error) { 13 + lang := strings.ToLower(strings.TrimSpace(name)) 14 + if len(lang) != 2 { 15 + return "", errors.New("language must have 2 characters") 16 + } 17 + for _, c := range lang { 18 + if c < 'a' || c > 'z' { 19 + return "", errors.New("language must have only ascii letters") 20 + } 21 + } 22 + return lang, nil 23 + } 24 + 25 + func confirmLanguage(name string, prompt bool) string { 26 + if !prompt { 27 + return name 28 + } 29 + for { 30 + var msg string 31 + if name == "" { 32 + msg = "Enter the language (2-letter ISO 639-1 code): " 33 + } else { 34 + msg = fmt.Sprintf("Is the post language %s? [y/n] ", name) 35 + } 36 + fmt.Print(msg) 37 + 38 + r := bufio.NewReader(os.Stdin) 39 + v, err := r.ReadString('\n') 40 + if err != nil { 41 + slog.Error("could not read input value", "error", err) 42 + } 43 + 44 + if name != "" && strings.ToLower(strings.TrimSpace(v)) == "y" { 45 + v = name 46 + } 47 + 48 + lang, err := newLanguage(v) 49 + if err != nil { 50 + slog.Error("invalid language", "value", v, "error", err) 51 + name = "" 52 + continue 53 + } 54 + 55 + return lang 56 + } 57 + }
+169
post/media.go
··· 1 + package post 2 + 3 + import ( 4 + "bufio" 5 + "encoding/binary" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "strings" 11 + 12 + "github.com/gabriel-vasile/mimetype" 13 + "golang.org/x/sync/errgroup" 14 + ) 15 + 16 + type Media struct { 17 + Mime string 18 + Alt string 19 + Reader io.ReadSeekCloser 20 + } 21 + 22 + func (m *Media) Close() error { 23 + return m.Reader.Close() 24 + } 25 + 26 + func (m *Media) Dimensions() (uint, uint, error) { 27 + if _, err := m.Reader.Seek(0, io.SeekStart); err != nil { 28 + return 0, 0, fmt.Errorf("could not seek media: %w", err) 29 + } 30 + 31 + switch m.Mime { 32 + case "image/png": 33 + if _, err := m.Reader.Seek(16, io.SeekStart); err != nil { 34 + return 0, 0, fmt.Errorf("could not seek PNG header: %w", err) 35 + } 36 + var w, h uint32 37 + if err := binary.Read(m.Reader, binary.BigEndian, &w); err != nil { 38 + return 0, 0, fmt.Errorf("could not read PNG width: %w", err) 39 + } 40 + if err := binary.Read(m.Reader, binary.BigEndian, &h); err != nil { 41 + return 0, 0, fmt.Errorf("could not read PNG height: %w", err) 42 + } 43 + return uint(w), uint(h), nil 44 + 45 + case "image/jpeg": 46 + for { 47 + var marker [2]byte 48 + if _, err := io.ReadFull(m.Reader, marker[:]); err != nil { 49 + return 0, 0, nil 50 + } 51 + if marker[1] == 0xd8 { 52 + continue 53 + } 54 + if marker[1] == 0xc0 || marker[1] == 0xc2 { 55 + if _, err := m.Reader.Seek(2, io.SeekCurrent); err != nil { 56 + return 0, 0, nil 57 + } 58 + if _, err := m.Reader.Seek(1, io.SeekCurrent); err != nil { 59 + return 0, 0, nil 60 + } 61 + var h, w uint16 62 + if err := binary.Read(m.Reader, binary.BigEndian, &h); err != nil { 63 + return 0, 0, nil 64 + } 65 + if err := binary.Read(m.Reader, binary.BigEndian, &w); err != nil { 66 + return 0, 0, nil 67 + } 68 + return uint(w), uint(h), nil 69 + } 70 + var n uint16 71 + if err := binary.Read(m.Reader, binary.BigEndian, &n); err != nil { 72 + return 0, 0, nil 73 + } 74 + if n < 2 { 75 + return 0, 0, nil 76 + } 77 + if _, err := m.Reader.Seek(int64(n-2), io.SeekCurrent); err != nil { 78 + return 0, 0, nil 79 + } 80 + } 81 + } 82 + 83 + return 0, 0, nil 84 + } 85 + 86 + func newMedia(pth string, alt string, limit int64) (*Media, error) { 87 + f, err := os.Open(pth) 88 + if err != nil { 89 + return nil, fmt.Errorf("could not open %s: %w", pth, err) 90 + } 91 + 92 + var ok bool 93 + defer func() { 94 + if !ok { 95 + if err := f.Close(); err != nil { 96 + slog.Warn("could not close file", "path", pth, "error", err) 97 + } 98 + } 99 + }() 100 + 101 + if limit > 0 { 102 + s, err := f.Stat() 103 + if err != nil { 104 + return nil, fmt.Errorf("could not read stats for %s: %w", pth, err) 105 + } 106 + if s.Size() > limit { 107 + return nil, fmt.Errorf("cannot upload image with %d bytes, the limit is %d bytes", s.Size(), limit) 108 + } 109 + } 110 + 111 + mime, err := mimetype.DetectReader(f) 112 + if err != nil { 113 + return nil, fmt.Errorf("could not detect mime type for %s: %w", pth, err) 114 + } 115 + 116 + if _, err := f.Seek(0, io.SeekStart); err != nil { 117 + return nil, fmt.Errorf("could not seek %s: %w", pth, err) 118 + } 119 + 120 + ok = true 121 + return &Media{mime.String(), alt, f}, nil 122 + } 123 + 124 + func promptAlt(pth string) (string, error) { 125 + fmt.Printf("Enter an alt text for %s: ", pth) 126 + r := bufio.NewReader(os.Stdin) 127 + v, err := r.ReadString('\n') 128 + if err != nil { 129 + return "", fmt.Errorf("could not read alt text: %w", err) 130 + } 131 + return strings.TrimSpace(v), nil 132 + } 133 + 134 + func NewMedias(pths, alts []string, limit int64, prompt bool) ([]*Media, error) { 135 + out := make([]*Media, len(pths)) 136 + 137 + var g errgroup.Group 138 + for idx := range len(pths) { 139 + g.Go(func() error { 140 + var alt string 141 + if idx < len(alts) { 142 + alt = alts[idx] 143 + } 144 + m, err := newMedia(pths[idx], alt, limit) 145 + if err != nil { 146 + return err 147 + } 148 + out[idx] = m 149 + return nil 150 + }) 151 + } 152 + if err := g.Wait(); err != nil { 153 + return nil, err 154 + } 155 + 156 + if prompt { 157 + for i, m := range out { 158 + if m.Alt == "" { 159 + alt, err := promptAlt(pths[i]) 160 + if err != nil { 161 + return nil, err 162 + } 163 + m.Alt = alt 164 + } 165 + } 166 + } 167 + 168 + return out, nil 169 + }
+114
post/media_test.go
··· 1 + package post 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + ) 7 + 8 + func TestMediaDimensionsPNG(t *testing.T) { 9 + m, err := newMedia(filepath.Join("..", "testdata", "image.png"), "", 0) 10 + if err != nil { 11 + t.Fatalf("expected no error opening PNG, got %s", err) 12 + } 13 + defer m.Close() 14 + 15 + w, h, err := m.Dimensions() 16 + if err != nil { 17 + t.Fatalf("expected no error reading PNG dimensions, got %s", err) 18 + } 19 + if w != 1 { 20 + t.Errorf("expected PNG width to be 1, got %d", w) 21 + } 22 + if h != 2 { 23 + t.Errorf("expected PNG height to be 2, got %d", h) 24 + } 25 + } 26 + 27 + func TestMediaDimensionsJPEG(t *testing.T) { 28 + m, err := newMedia(filepath.Join("..", "testdata", "image.jpeg"), "", 0) 29 + if err != nil { 30 + t.Fatalf("expected no error opening JPEG, got %s", err) 31 + } 32 + defer m.Close() 33 + 34 + w, h, err := m.Dimensions() 35 + if err != nil { 36 + t.Fatalf("expected no error reading JPEG dimensions, got %s", err) 37 + } 38 + if w != 2 { 39 + t.Errorf("expected JPEG width to be 2, got %d", w) 40 + } 41 + if h != 3 { 42 + t.Errorf("expected JPEG height to be 3, got %d", h) 43 + } 44 + } 45 + 46 + func TestNewMediaSizeLimit(t *testing.T) { 47 + _, err := newMedia(filepath.Join("..", "testdata", "image.png"), "", 1) 48 + if err == nil { 49 + t.Error("expected error for image exceeding size limit, got nil") 50 + } 51 + } 52 + 53 + func TestNewMediasOrder(t *testing.T) { 54 + png := filepath.Join("..", "testdata", "image.png") 55 + jpeg := filepath.Join("..", "testdata", "image.jpeg") 56 + pths := []string{png, jpeg, png, jpeg} 57 + 58 + got, err := NewMedias(pths, nil, 0, false) 59 + if err != nil { 60 + t.Fatalf("expected no error, got %s", err) 61 + } 62 + for i, m := range got { 63 + defer m.Close() 64 + w, _, err := m.Dimensions() 65 + if err != nil { 66 + t.Fatalf("expected no error reading dimensions for item %d, got %s", i, err) 67 + } 68 + var want uint 69 + if i%2 == 0 { 70 + want = 1 // PNG width 71 + } else { 72 + want = 2 // JPEG width 73 + } 74 + if w != want { 75 + t.Errorf("expected item %d width to be %d, got %d", i, want, w) 76 + } 77 + } 78 + } 79 + 80 + func TestNewMediasAltTexts(t *testing.T) { 81 + png := filepath.Join("..", "testdata", "image.png") 82 + pths := []string{png, png} 83 + alts := []string{"first", "second"} 84 + 85 + got, err := NewMedias(pths, alts, 0, false) 86 + if err != nil { 87 + t.Fatalf("expected no error, got %s", err) 88 + } 89 + for i, m := range got { 90 + defer m.Close() 91 + if m.Alt != alts[i] { 92 + t.Errorf("expected alt text to be %q for item %d, got %q", alts[i], i, m.Alt) 93 + } 94 + } 95 + } 96 + 97 + func TestNewMediasPartialAltTexts(t *testing.T) { 98 + png := filepath.Join("..", "testdata", "image.png") 99 + pths := []string{png, png} 100 + alts := []string{"only-first"} 101 + 102 + got, err := NewMedias(pths, alts, 0, false) 103 + if err != nil { 104 + t.Fatalf("expected no error, got %s", err) 105 + } 106 + defer got[0].Close() 107 + defer got[1].Close() 108 + if got[0].Alt != "only-first" { 109 + t.Errorf("expected first alt to be %q, got %q", "only-first", got[0].Alt) 110 + } 111 + if got[1].Alt != "" { 112 + t.Errorf("expected second alt to be empty, got %q", got[1].Alt) 113 + } 114 + }
+58
post/post.go
··· 1 + package post 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "strings" 9 + "unicode/utf8" 10 + 11 + "github.com/pemistahl/lingua-go" 12 + ) 13 + 14 + type Post struct { 15 + Text string 16 + Media []*Media 17 + Language string 18 + } 19 + 20 + func (p *Post) Empty() bool { 21 + if p.Text != "" { 22 + return false 23 + } 24 + if len(p.Media) > 0 { 25 + return false 26 + } 27 + return true 28 + } 29 + 30 + func NewPost(txt string, media []*Media, lang string, limit int, prompt bool) (*Post, error) { 31 + txt = strings.TrimSpace(txt) 32 + if utf8.RuneCountInString(txt) > limit { 33 + errTooLong := fmt.Errorf("text cannot be longer than %d characters, this text is %d characters long", limit, utf8.RuneCountInString(txt)) 34 + tmp, err := os.CreateTemp("", "not-my-ex-draft-*") 35 + if err != nil { 36 + return nil, errors.Join(errTooLong, fmt.Errorf("draft could not be saved: %w", err)) 37 + } 38 + defer func() { 39 + if err := tmp.Close(); err != nil { 40 + slog.Warn("could not close draft", "file", tmp.Name(), "error", err) 41 + } 42 + }() 43 + if _, err := tmp.WriteString(txt); err != nil { 44 + return nil, errors.Join(errTooLong, fmt.Errorf("draft could not be saved: %w", err)) 45 + } 46 + return nil, fmt.Errorf("failed to post, your draft is saved at %s: %w", tmp.Name(), errTooLong) 47 + } 48 + 49 + if lang == "" { 50 + det := lingua.NewLanguageDetectorBuilder().FromAllLanguages() 51 + res, ok := det.Build().DetectLanguageOf(txt) 52 + if ok { 53 + lang = res.IsoCode639_1().String() 54 + } 55 + } 56 + 57 + return &Post{txt, media, confirmLanguage(lang, prompt)}, nil 58 + }
+120
post/post_test.go
··· 1 + package post 2 + 3 + import ( 4 + "os" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func TestNewLanguageValid(t *testing.T) { 10 + for _, c := range []string{"en", "pt", "FR", " de "} { 11 + got, err := newLanguage(c) 12 + if err != nil { 13 + t.Errorf("expected no error for %q, got %s", c, err) 14 + } 15 + if len(got) != 2 { 16 + t.Errorf("expected 2-char language for %q, got %q", c, got) 17 + } 18 + } 19 + } 20 + 21 + func TestNewLanguageInvalid(t *testing.T) { 22 + for _, c := range []string{"", "e", "eng", "e1", "42"} { 23 + if _, err := newLanguage(c); err == nil { 24 + t.Errorf("expected error for %q, got nil", c) 25 + } 26 + } 27 + } 28 + 29 + func TestNewPostTooLong(t *testing.T) { 30 + _, err := NewPost(strings.Repeat("a", 301), nil, "en", 300, false) 31 + if err == nil { 32 + t.Fatal("expected error for text exceeding limit, got nil") 33 + } 34 + if !strings.Contains(err.Error(), "300") { 35 + t.Errorf("expected error to mention limit 300, got %s", err) 36 + } 37 + } 38 + 39 + func TestNewPostMultiByteWithinLimit(t *testing.T) { 40 + // 300 CJK characters = 900 bytes, should not exceed the 300-character limit 41 + p, err := NewPost(strings.Repeat("ไฝ ", 300), nil, "zh", 300, false) 42 + if err != nil { 43 + t.Fatalf("expected no error for 300 multi-byte characters within limit, got %s", err) 44 + } 45 + if p.Text != strings.Repeat("ไฝ ", 300) { 46 + t.Error("expected text to be preserved") 47 + } 48 + } 49 + 50 + func TestNewPostMultiByteTooLong(t *testing.T) { 51 + // 301 CJK characters should exceed the 300-character limit 52 + _, err := NewPost(strings.Repeat("ไฝ ", 301), nil, "zh", 300, false) 53 + if err == nil { 54 + t.Fatal("expected error for 301 multi-byte characters exceeding limit, got nil") 55 + } 56 + } 57 + 58 + func TestNewPostTooLongSavesDraft(t *testing.T) { 59 + _, err := NewPost(strings.Repeat("a", 301), nil, "en", 300, false) 60 + if err == nil { 61 + t.Fatal("expected error, got nil") 62 + } 63 + msg := err.Error() 64 + idx := strings.Index(msg, "your draft is saved at ") 65 + if idx == -1 { 66 + t.Fatalf("expected error to mention draft path, got %s", err) 67 + } 68 + rest := msg[idx+len("your draft is saved at "):] 69 + pth := rest[:strings.Index(rest, ":")] 70 + if _, statErr := os.Stat(pth); statErr != nil { 71 + t.Errorf("expected draft file to exist at %q, got %s", pth, statErr) 72 + } 73 + } 74 + 75 + func TestNewPostValid(t *testing.T) { 76 + p, err := NewPost("forty-two", nil, "en", 300, false) 77 + if err != nil { 78 + t.Fatalf("expected no error, got %s", err) 79 + } 80 + if p.Text != "forty-two" { 81 + t.Errorf("expected text to be %q, got %q", "forty-two", p.Text) 82 + } 83 + if p.Language != "en" { 84 + t.Errorf("expected language to be %q, got %q", "en", p.Language) 85 + } 86 + } 87 + 88 + func TestNewPostTrimsWhitespace(t *testing.T) { 89 + p, err := NewPost(" hello ", nil, "en", 300, false) 90 + if err != nil { 91 + t.Fatalf("expected no error, got %s", err) 92 + } 93 + if p.Text != "hello" { 94 + t.Errorf("expected text to be %q, got %q", "hello", p.Text) 95 + } 96 + } 97 + 98 + func TestPostEmpty(t *testing.T) { 99 + cases := []struct { 100 + txt string 101 + media []*Media 102 + expected bool 103 + }{ 104 + {"forty-two", nil, false}, 105 + {"", nil, true}, 106 + } 107 + for _, c := range cases { 108 + p := &Post{Text: c.txt, Media: c.media} 109 + if got := p.Empty(); got != c.expected { 110 + t.Errorf("expected Empty() to be %v for text %q, got %v", c.expected, c.txt, got) 111 + } 112 + } 113 + } 114 + 115 + func TestPostEmptyWithMedia(t *testing.T) { 116 + p := &Post{Text: "", Media: []*Media{{Mime: "image/png"}}} 117 + if p.Empty() { 118 + t.Error("expected non-empty post with media, got empty") 119 + } 120 + }
-69
pyproject.toml
··· 1 - [project] 2 - name = "not-my-ex" 3 - version = "1.2.2" 4 - description = "Tiny CLI to post simultaneously to Mastodon and Bluesky" 5 - authors = [{name = "Eduardo Cuducos", email = "4732915+cuducos@users.noreply.github.com"}] 6 - license = {file = "LICENSE"} 7 - readme = "README.md" 8 - requires-python = ">=3.10,<3.15" 9 - classifiers = [ 10 - "Intended Audience :: Developers", 11 - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 12 - "Programming Language :: Python :: 3.10", 13 - "Programming Language :: Python :: 3.11", 14 - "Programming Language :: Python :: 3.12", 15 - "Programming Language :: Python :: 3.13", 16 - "Programming Language :: Python :: 3.14", 17 - ] 18 - dependencies = [ 19 - "aiofiles>=25.1.0", 20 - "appdirs>=1.4.4", 21 - "backoff>=2.2.1", 22 - "beautifulsoup4>=4.12.3", 23 - "cryptography>=43.0.1", 24 - "eld>=1.0.6", 25 - "httpx>=0.28.1", 26 - "typer>=0.12.5", 27 - ] 28 - 29 - [project.urls] 30 - Repository = "https://codeberg.org/cuducos/not-my-ex" 31 - Changelog = "https://codeberg.org/cuducos/not-my-ex/src/branch/main/CHANGELOG.md" 32 - Bug = "https://codeberg.org/cuducos/not-my-ex/issues" 33 - Funding = "https://github.com/sponsors/cuducos" 34 - 35 - [project.scripts] 36 - "not-my-ex" = "not_my_ex.__main__:cli" 37 - 38 - [dependency-groups] 39 - dev = [ 40 - "ipdb==0.13.13", 41 - "pytest-asyncio==1.3.0", 42 - "pytest-mypy==1.0.1", 43 - "pytest-ruff==0.5", 44 - "tox-uv==1.29.0", 45 - "types-aiofiles==25.1.0.20251011", 46 - "types-appdirs==1.4.3.5", 47 - "types-beautifulsoup4==4.12.0.20250516", 48 - ] 49 - 50 - [tool.pytest.ini_options] 51 - addopts = "--ruff --ruff-format --mypy --pdbcls=IPython.terminal.debugger:Pdb" 52 - asyncio_default_fixture_loop_scope = "module" 53 - 54 - [tool.ruff.lint] 55 - select = ["A", "E", "F", "I", "N", "SIM", "TID", "UP"] 56 - 57 - [tool.ruff.lint.isort] 58 - split-on-trailing-comma = false 59 - 60 - [tool.tox] 61 - env_list = ["3.10", "3.11", "3.12", "3.13", "3.14"] 62 - 63 - [tool.tox.env_run_base] 64 - description = "Run test under {base_python}" 65 - commands = [["pytest"]] 66 - 67 - [build-system] 68 - requires = ["hatchling"] 69 - build-backend = "hatchling.build"
testdata/auth.pickle

This is a binary file and will not be displayed.

+9
testdata/card.html
··· 1 + <html> 2 + <head> 3 + <meta property="og:title" content="Sample Title"> 4 + <meta property="og:url" content="SERVER_URL/page"> 5 + <meta property="og:description" content="A description"> 6 + <meta property="og:image" content="SERVER_URL/image.jpeg"> 7 + </head> 8 + <body></body> 9 + </html>
+7
testdata/card_no_thumbnail.html
··· 1 + <html> 2 + <head> 3 + <meta property="og:title" content="Title"> 4 + <meta property="og:url" content="http://example.com"> 5 + </head> 6 + <body></body> 7 + </html>
+6
testdata/card_no_title.html
··· 1 + <html> 2 + <head> 3 + <meta property="og:url" content="http://example.com"> 4 + </head> 5 + <body></body> 6 + </html>
+6
testdata/card_no_url.html
··· 1 + <html> 2 + <head> 3 + <meta property="og:title" content="Title"> 4 + </head> 5 + <body></body> 6 + </html>
+9
testdata/card_relative_image.html
··· 1 + <html> 2 + <head> 3 + <meta property="og:title" content="Sample Title"> 4 + <meta property="og:url" content="SERVER_URL/page"> 5 + <meta property="og:description" content="A description"> 6 + <meta property="og:image" content="/image.jpeg"> 7 + </head> 8 + <body></body> 9 + </html>
+2
testdata/encrypted.pickle
··· 1 + ีŠZ๏ฟฝj๏ฟฝd๏ฟฝ๏ฟฝ๏ฟฝU,๏ฟฝ๏ฟฝ๏ฟฝc$:"๏ฟฝ})n^ 2 + สŠ!K+/mM๏ฟฝRgAAAAABpP55J_BSUbhHz1IPNVm1rVbM4oCMwR1dNjIwdiSQFyr5rRBdlWhw3cY3iYazCfeeL_pbOxNZ9BI7SG_I1MfoFSaJsjizRPNYy0TFZPl7cxGxO6yrPuklOYhDQReQa1JjGNlcMMIYF7FDaGEV5XR9SrANhcT8KWL0WjQ2nkAcIu9zwcDaq1OMoQolwlr3AF1xhyX-OwbY3ZnvvvM3C3Q4nrLh6tbB2jEAr26LBxJV5ztwMZR4x7GHyNpZ9WriEPkZoyj3MbaBIQVUxuOSupA-ErXiBbz4g9xgkgTL2CkfKPjrQTIk4xLhIZ_Avo7iIHHBB_devqT3hSv_F5_i1UxPrq4b7_7gF05lkmnaF06EnHGxIlPaaSQVnR--MCWuiX_y2Th1zJe8gL6cYc1la_gYD3yPuUg==
-22
tests/conftest.py
··· 1 - from os import environ 2 - 3 - from pytest import fixture 4 - 5 - from not_my_ex.auth import EnvAuth 6 - 7 - SETTINGS = { 8 - "BSKY_EMAIL": "python@mailinator.com", 9 - "BSKY_PASSWORD": "forty2", 10 - "MASTODON_TOKEN": "40two", 11 - } 12 - 13 - 14 - def pytest_configure(config): 15 - for key, value in SETTINGS.items(): 16 - environ[f"NOT_MY_EX_{key}"] = value 17 - return config 18 - 19 - 20 - @fixture 21 - def auth(): 22 - return EnvAuth()
tests/image.jpeg testdata/image.jpeg
tests/image.png testdata/image.png
-71
tests/test_auth.py
··· 1 - from pathlib import Path 2 - from tempfile import TemporaryDirectory 3 - from unittest.mock import patch 4 - 5 - from cryptography.fernet import InvalidToken 6 - from pytest import fixture, raises 7 - 8 - from not_my_ex.auth import EncryptedAuth 9 - from not_my_ex.settings import DEFAULT_BLUESKY_AGENT, DEFAULT_MASTODON_INSTANCE 10 - 11 - PASSWORD = "forty2" 12 - BSKY_EMAIL = "email" 13 - BSKY_PASSWORD = "password" 14 - BSKY_AGENT = "agent" 15 - MSTDN_TOKEN = "token" 16 - MSTDN_INSTANCE = "instance" 17 - LANG = "pt" 18 - 19 - 20 - @fixture(autouse=True) 21 - def user_cache_dir(): 22 - with TemporaryDirectory() as tmp: 23 - path = Path(tmp) 24 - app_cache = str(path / "not-my-ex") 25 - with patch("not_my_ex.auth.user_cache_dir", return_value=app_cache): 26 - yield 27 - 28 - 29 - def test_assert_auth_with_right_password(): 30 - auth = EncryptedAuth(PASSWORD) 31 - assert not auth.path.exists() 32 - assert not auth.bluesky 33 - assert not auth.mastodon 34 - 35 - auth.save_language(LANG) 36 - assert auth.path.exists() 37 - assert auth.language == LANG 38 - assert not auth.bluesky 39 - assert not auth.mastodon 40 - 41 - auth.save_bluesky(BSKY_EMAIL, BSKY_PASSWORD) 42 - assert auth.language == LANG 43 - assert auth.bluesky.email == BSKY_EMAIL 44 - assert auth.bluesky.password == BSKY_PASSWORD 45 - assert auth.bluesky.agent == DEFAULT_BLUESKY_AGENT 46 - assert not auth.mastodon 47 - 48 - auth.save_mastodon(MSTDN_TOKEN) 49 - assert auth.language == LANG 50 - assert auth.bluesky.email == BSKY_EMAIL 51 - assert auth.bluesky.password == BSKY_PASSWORD 52 - assert auth.bluesky.agent == DEFAULT_BLUESKY_AGENT 53 - assert auth.mastodon.token == MSTDN_TOKEN 54 - assert auth.mastodon.instance == DEFAULT_MASTODON_INSTANCE 55 - 56 - 57 - def test_assert_auth_with_right_password_and_custom_instances(): 58 - auth = EncryptedAuth(PASSWORD) 59 - auth.save_bluesky(BSKY_EMAIL, BSKY_PASSWORD, BSKY_AGENT) 60 - auth.save_mastodon(MSTDN_TOKEN, MSTDN_INSTANCE) 61 - assert auth.bluesky.agent == BSKY_AGENT 62 - assert auth.mastodon.instance == MSTDN_INSTANCE 63 - 64 - 65 - def test_assert_auth_wrong_password_attempt(): 66 - auth = EncryptedAuth(PASSWORD) 67 - auth.save_bluesky(BSKY_EMAIL, BSKY_PASSWORD, BSKY_AGENT) 68 - auth.save_mastodon(MSTDN_TOKEN, MSTDN_INSTANCE) 69 - 70 - with raises(InvalidToken): 71 - EncryptedAuth("admin")
-200
tests/test_bluesky.py
··· 1 - from pathlib import Path 2 - from unittest.mock import ANY, AsyncMock, Mock 3 - 4 - from pytest import mark, raises 5 - 6 - from not_my_ex.bluesky import Bluesky 7 - from not_my_ex.client import ClientError 8 - from not_my_ex.media import Media 9 - from not_my_ex.post import Post 10 - 11 - 12 - @mark.asyncio 13 - async def test_bluesky_client_uses_the_correct_credentials(auth): 14 - client, response = AsyncMock(), Mock() 15 - response.status_code = 200 16 - response.json.return_value = { 17 - "accessJwt": "a very long string", 18 - "did": "42", 19 - "handle": "cuducos", 20 - } 21 - client.post.return_value = response 22 - await Bluesky(client, auth.bluesky).auth() 23 - client.post.assert_called_once_with( 24 - f"{auth.bluesky.agent}/xrpc/com.atproto.server.createSession", 25 - json={"identifier": auth.bluesky.email, "password": auth.bluesky.password}, 26 - ) 27 - 28 - 29 - @mark.asyncio 30 - async def test_bluesky_client_raises_error_for_invalid_credentials(auth): 31 - client, response = AsyncMock(), Mock() 32 - response.status_code = 401 33 - response.json.return_value = {"error": "SomeError", "message": "Oops"} 34 - client.post.return_value = response 35 - with raises(ClientError, match="SomeError: Oops"): 36 - await Bluesky(client, auth.bluesky).auth() 37 - 38 - 39 - @mark.asyncio 40 - async def test_bluesky_client_gets_a_jwt_token_and_did(auth): 41 - client, response = AsyncMock(), Mock() 42 - response.status_code = 200 43 - response.json.return_value = { 44 - "accessJwt": "a very long string", 45 - "did": "42", 46 - "handle": "cuducos", 47 - } 48 - client.post.return_value = response 49 - bluesky = Bluesky(client, auth.bluesky) 50 - await bluesky.auth() 51 - assert bluesky.token == "a very long string" 52 - assert bluesky.did == "42" 53 - assert bluesky.handle == "cuducos" 54 - 55 - 56 - @mark.asyncio 57 - async def test_bluesky_client_post_data(auth): 58 - client, response = AsyncMock(), Mock() 59 - client.post.return_value = response 60 - bluesky = Bluesky(client, auth.bluesky) 61 - bluesky.is_authenticated = True 62 - bluesky.did = "42" 63 - data = await bluesky.data(Post("hello world, the answer is 42")) 64 - assert data == { 65 - "repo": "42", 66 - "collection": "app.bsky.feed.post", 67 - "record": { 68 - "$type": "app.bsky.feed.post", 69 - "text": "hello world, the answer is 42", 70 - "createdAt": ANY, 71 - "langs": ["en"], 72 - }, 73 - } 74 - 75 - 76 - @mark.asyncio 77 - async def test_bluesky_client_post(auth): 78 - client, response = AsyncMock(), Mock() 79 - response.status_code = 200 80 - response.json.return_value = { 81 - "uri": "at://did:plc:42/app.bsky.feed.post/fOrTy2", 82 - "cid": "meh", 83 - } 84 - client.post.return_value = response 85 - bluesky = Bluesky(client, auth.bluesky) 86 - bluesky.is_authenticated = True 87 - bluesky.token = "forty-two" 88 - bluesky.did = "42" 89 - bluesky.handle = "cuducos" 90 - post = Post("Hello") 91 - assert await bluesky.post(post) == "https://bsky.app/profile/cuducos/post/fOrTy2" 92 - 93 - 94 - @mark.asyncio 95 - async def test_bluesky_client_post_raises_error_from_server(auth): 96 - client, response = AsyncMock(), Mock() 97 - response.status_code = 501 98 - response.json.return_value = { 99 - "error": "SomeError", 100 - "message": "Oops", 101 - } 102 - client.post.return_value = response 103 - bluesky = Bluesky(client, auth.bluesky) 104 - bluesky.is_authenticated = True 105 - post = Post("Hello") 106 - with raises(ClientError): 107 - await bluesky.post(post) 108 - 109 - 110 - @mark.asyncio 111 - async def test_bluesky_client_post_data_includes_urls_in_facets(auth): 112 - client = AsyncMock() 113 - bluesky = Bluesky(client, auth.bluesky) 114 - bluesky.is_authenticated = True 115 - text = "โœจ example mentioning @atproto.com to share the URL ๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ https://en.wikipedia.org/wiki/CBOR." 116 - data = await bluesky.data(Post(text)) 117 - assert data["record"]["facets"] == [ 118 - { 119 - "index": {"byteStart": 74, "byteEnd": 108}, 120 - "features": [ 121 - { 122 - "$type": "app.bsky.richtext.facet#link", 123 - "uri": "https://en.wikipedia.org/wiki/CBOR", 124 - } 125 - ], 126 - } 127 - ] 128 - 129 - 130 - @mark.asyncio 131 - async def test_bluesky_client_post_data_includes_urls_and_hashtag_in_facets(auth): 132 - client = AsyncMock() 133 - bluesky = Bluesky(client, auth.bluesky) 134 - bluesky.is_authenticated = True 135 - text = "โœจ example mentioning @atproto.com to #share the URL ๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ https://en.wikipedia.org/wiki/CBOR#42." 136 - data = await bluesky.data(Post(text)) 137 - assert data["record"]["facets"] == [ 138 - { 139 - "features": [ 140 - { 141 - "$type": "app.bsky.richtext.facet#link", 142 - "uri": "https://en.wikipedia.org/wiki/CBOR#42", 143 - }, 144 - ], 145 - "index": { 146 - "byteEnd": 112, 147 - "byteStart": 75, 148 - }, 149 - }, 150 - { 151 - "features": [ 152 - { 153 - "$type": "app.bsky.richtext.facet#tag", 154 - "tag": "share", 155 - }, 156 - ], 157 - "index": { 158 - "byteEnd": 45, 159 - "byteStart": 39, 160 - }, 161 - }, 162 - ] 163 - 164 - 165 - @mark.asyncio 166 - async def test_bluesky_client_post_data_includes_images_blobs(auth): 167 - png = Path(__file__).parent / "image.png" 168 - client, response = AsyncMock(), Mock() 169 - response.status_code = 200 170 - response.json.return_value = {"blob": "42"} 171 - client.post.return_value = response 172 - bluesky = Bluesky(client, auth.bluesky) 173 - bluesky.is_authenticated = True 174 - bluesky.token = "token" 175 - bluesky.did = "did" 176 - media = await Media.from_img(str(png), "my alt text") 177 - post = Post("hi", media=(media,)) 178 - data = await bluesky.data(post) 179 - client.post.assert_any_call( 180 - f"{auth.bluesky.agent}/xrpc/com.atproto.repo.uploadBlob", 181 - headers={ 182 - "Authorization": "Bearer token", 183 - "Content-type": "image/png", 184 - }, 185 - data=png.read_bytes(), 186 - ) 187 - assert data["record"]["embed"] == { 188 - "$type": "app.bsky.embed.images", 189 - "images": [ 190 - { 191 - "image": "42", 192 - "alt": "my alt text", 193 - "aspectRatio": { 194 - "$type": "app.bsky.embed.defs", 195 - "width": 1, 196 - "height": 2, 197 - }, 198 - } 199 - ], 200 - }
-57
tests/test_card.py
··· 1 - from unittest.mock import AsyncMock, patch 2 - 3 - from httpx import AsyncClient, HTTPStatusError, Request, Response 4 - from pytest import mark 5 - 6 - from not_my_ex.card import Card 7 - 8 - MOCK_HTML = """ 9 - <html> 10 - <head> 11 - <meta property="og:title" content="Sample Page Title"> 12 - <meta property="og:url" content="http://example.com/sample-page"> 13 - <meta property="og:description" content="This is a sample description"> 14 - <meta property="og:image" content="http://example.com/sample-image.jpg"> 15 - </head> 16 - <body></body> 17 - </html> 18 - """ 19 - MOCK_IMAGE = b"fake_image_bytes" 20 - 21 - 22 - @mark.asyncio 23 - async def test_card_success(): 24 - with patch.object( 25 - AsyncClient, 26 - "get", 27 - AsyncMock( 28 - side_effect=[ 29 - Response(200, content=MOCK_HTML.encode("utf-8")), 30 - Response(200, content=MOCK_IMAGE), 31 - ] 32 - ), 33 - ): 34 - card = await Card.from_url("http://example.com/sample-page") 35 - assert card is not None 36 - assert card.title == "Sample Page Title" 37 - assert card.uri == "http://example.com/sample-page" 38 - assert card.description == "This is a sample description" 39 - assert card.thumb == MOCK_IMAGE 40 - assert card.mime == "image/jpeg" 41 - 42 - 43 - @mark.asyncio 44 - async def test_card_failure(): 45 - with patch.object( 46 - AsyncClient, 47 - "get", 48 - AsyncMock( 49 - side_effect=HTTPStatusError( 50 - "Error", 51 - request=Request("GET", "http://example.com/sample-page"), 52 - response=Response(404), 53 - ) 54 - ), 55 - ): 56 - card = await Card.from_url("http://example.com/sample-page") 57 - assert card is None
-24
tests/test_cli_post.py
··· 1 - from unittest.mock import patch 2 - 3 - from not_my_ex.cli.post import post 4 - 5 - 6 - def test_post_command_event_loop(): 7 - """There was a relevant change in Async I/O API in Python 3.14, when getting the 8 - event loop. This test assures the CLI works on the supported Python versions.""" 9 - with ( 10 - patch("not_my_ex.cli.post.cache") as cache, 11 - patch("not_my_ex.cli.post.authenticate") as auth, 12 - ): 13 - cache.return_value.exists.return_value = False 14 - auth.return_value.assure_configured.return_value = None 15 - with patch("not_my_ex.cli.post.main", return_value=None): 16 - post( 17 - text="test", 18 - images=[], 19 - alt_texts=[], 20 - lang="en", 21 - yes_to_all=True, 22 - skip_bluesky=False, 23 - skip_mastodon=False, 24 - )
-28
tests/test_langauge.py
··· 1 - from unittest.mock import patch 2 - 3 - from not_my_ex.language import Language 4 - 5 - 6 - def test_language_with_valid_value(): 7 - with patch("not_my_ex.language.input") as mock: 8 - lang = Language(" PT") 9 - mock.assert_not_called() 10 - assert lang.name == "pt" 11 - 12 - 13 - def test_language_ask_without_value(): 14 - with patch("not_my_ex.language.input") as mock: 15 - mock.return_value = "pT" 16 - lang = Language() 17 - lang.ask() 18 - mock.assert_called_once() 19 - assert lang.name == "pt" 20 - 21 - 22 - def test_language_ask_with_invalid_value(): 23 - with patch("not_my_ex.language.input") as mock: 24 - mock.side_effect = ("pt-BR", "pt") 25 - lang = Language() 26 - lang.ask() 27 - assert lang.name == "pt" 28 - assert mock.call_count == 2
-97
tests/test_mastodon.py
··· 1 - from unittest.mock import ANY, AsyncMock, Mock, patch 2 - 3 - from pytest import mark, raises 4 - 5 - from not_my_ex.client import ClientError 6 - from not_my_ex.mastodon import Mastodon 7 - from not_my_ex.media import Media 8 - from not_my_ex.post import Post 9 - 10 - 11 - @mark.asyncio 12 - async def test_mastodon_client_post_raises_error_from_server(auth): 13 - client, response = AsyncMock(), Mock() 14 - response.status_code = 401 15 - response.json.return_value = {"error": "oops"} 16 - client.post.return_value = response 17 - post = Post("Hello") 18 - with raises(ClientError): 19 - await Mastodon(client, auth.mastodon).post(post) 20 - 21 - 22 - @mark.asyncio 23 - async def test_mastodon_client_post(auth): 24 - client, response = AsyncMock(), Mock() 25 - response.status_code = 200 26 - response.json.return_value = {"url": "https://tech.lgbt/@cuducos/42"} 27 - client.post.return_value = response 28 - mastodon = Mastodon(client, auth.mastodon) 29 - post = Post("Hello world, the answer is 42") 30 - 31 - assert await mastodon.post(post) == "https://tech.lgbt/@cuducos/42" 32 - client.post.assert_called_once_with( 33 - f"{auth.mastodon.instance}/api/v1/statuses", 34 - headers={"Authorization": "Bearer 40two"}, 35 - json={"status": "Hello world, the answer is 42", "language": "en"}, 36 - ) 37 - 38 - 39 - @mark.asyncio 40 - async def test_mastodon_client_upload(auth): 41 - client, response = AsyncMock(), Mock() 42 - response.status_code = 200 43 - response.json.return_value = {"id": 42} 44 - client.post.return_value = response 45 - mastodon = Mastodon(client, auth.mastodon) 46 - media = Media("/tmp/42.png", b"42", "image/png", "desc") 47 - 48 - resp = await mastodon.upload(media) 49 - assert resp == "42" 50 - client.post.assert_called_once_with( 51 - f"{auth.mastodon.instance}/api/v2/media", 52 - headers={"Authorization": "Bearer 40two"}, 53 - data={"description": "desc"}, 54 - files={"file": ("image.png", ANY, "image/png")}, 55 - ) 56 - 57 - 58 - @mark.asyncio 59 - async def test_mastodon_client_upload_with_post_processing(auth): 60 - client, response = AsyncMock(), Mock() 61 - response.status_code = 202 62 - response.json.return_value = {"id": 42} 63 - processing, ok = Mock(), Mock() 64 - processing.status_code = 206 65 - ok.status_code = 200 66 - client.post.return_value = response 67 - client.get.side_effect = (processing, ok) 68 - mastodon = Mastodon(client, auth.mastodon) 69 - media = Media("/tmp/42.png", b"42", "image/png", "desc") 70 - assert await mastodon.upload(media) == "42" 71 - assert client.get.call_count == 2 72 - 73 - 74 - @mark.asyncio 75 - async def test_mastodon_client_post_with_media(auth): 76 - with patch.object(Mastodon, "upload", new_callable=AsyncMock) as upload: 77 - upload.return_value = 42 78 - client, response = AsyncMock(), Mock() 79 - response.status_code = 200 80 - response.json.return_value = {"url": "https://tech.lgbt/@cuducos/42"} 81 - client.post.return_value = response 82 - mastodon = Mastodon(client, auth.mastodon) 83 - post = Post( 84 - "Hello world, the answer is 42", 85 - media=(Media("/tmp/42.png", b"42", "image/png", "my alt text"),), 86 - ) 87 - 88 - assert await mastodon.post(post) == "https://tech.lgbt/@cuducos/42" 89 - client.post.assert_called_once_with( 90 - f"{auth.mastodon.instance}/api/v1/statuses", 91 - headers={"Authorization": "Bearer 40two"}, 92 - json={ 93 - "status": "Hello world, the answer is 42", 94 - "language": "en", 95 - "media_ids": [42], 96 - }, 97 - )
-52
tests/test_media.py
··· 1 - from pathlib import Path 2 - from unittest.mock import patch 3 - 4 - from pytest import mark, raises 5 - 6 - from not_my_ex.media import Media 7 - 8 - 9 - @mark.asyncio 10 - @mark.parametrize("ext,width,height", (("png", 1, 2), ("jpeg", 2, 3))) 11 - async def test_media_from_img(ext, width, height): 12 - img = Path(__file__).parent / f"image.{ext}" 13 - media = await Media.from_img(str(img)) 14 - assert media.content == img.read_bytes() 15 - assert media.mime == f"image/{ext}" 16 - assert await media.dimensions() == (width, height) 17 - 18 - 19 - @mark.asyncio 20 - async def test_media_from_img_not_found(): 21 - with raises(ValueError): 22 - await Media.from_img("/tmp/this-file-should-not-exist.png") 23 - 24 - 25 - @mark.asyncio 26 - async def test_media_from_img_without_mime_type(): 27 - img = Path(__file__).parent / "image.png" 28 - with patch("not_my_ex.media.mime_for") as guess: 29 - guess.return_value = None 30 - with raises(ValueError): 31 - await Media.from_img(str(img)) 32 - 33 - 34 - def test_media_check_alt_text_with_existing_alt_text(): 35 - media = Media("path", b"content", "mime", "alt") 36 - with patch("not_my_ex.media.input") as mock: 37 - media.check_alt_text() 38 - mock.assert_not_called() 39 - 40 - 41 - def test_media_check_alt_text_with_user_input(): 42 - media = Media("path", b"content", "mime") 43 - with patch("not_my_ex.media.input") as mock: 44 - mock.return_value = "alt" 45 - media.check_alt_text() 46 - assert media.alt == "alt" 47 - 48 - 49 - @mark.asyncio 50 - async def test_media_dimensions_for_not_an_image(): 51 - media = await Media.from_img(__file__) 52 - assert await media.dimensions() is None
-73
tests/test_post.py
··· 1 - from pathlib import Path 2 - from unittest.mock import patch 3 - 4 - from pytest import mark, raises 5 - 6 - from not_my_ex.media import Media 7 - from not_my_ex.post import Post, PostTooLongError 8 - 9 - 10 - def test_post(): 11 - post = Post("forty-two") 12 - assert post.text == "forty-two" 13 - assert post.media is None 14 - assert post.lang == "en" 15 - 16 - 17 - @mark.asyncio 18 - async def test_post_with_media(): 19 - img = Path(__file__).parent / "image.png" 20 - media = await Media.from_img(str(img)) 21 - post = Post("forty-two", media=(media,)) 22 - assert len(post.media) == 1 23 - 24 - 25 - def test_post_raises_error_when_too_long(): 26 - with raises(PostTooLongError): 27 - Post("forty-two" * 42) 28 - 29 - 30 - @mark.parametrize("answer", ("y", "Y")) 31 - def test_post_check_right_language(answer): 32 - with patch("not_my_ex.post.input") as mock: 33 - mock.return_value = answer 34 - post = Post("Here comes a beautiful post") 35 - 36 - with patch("not_my_ex.post.Language") as lang: 37 - post.check_language() 38 - lang.assert_not_called() 39 - 40 - 41 - @mark.parametrize("answer", ("", " ", "N", "xpto")) 42 - def test_post_check_wrong_language(answer): 43 - with patch("not_my_ex.post.input") as mock: 44 - mock.return_value = answer 45 - post = Post("Here comes a beautiful post") 46 - assert post.lang != "pt" 47 - 48 - with patch("not_my_ex.post.Language") as lang: 49 - lang.return_value.name = "pt" 50 - post.check_language() 51 - 52 - assert post.lang == "pt" 53 - 54 - 55 - @mark.asyncio 56 - @mark.parametrize( 57 - "text, with_image, expected", 58 - ( 59 - ("forty-two", True, False), 60 - ("", True, False), 61 - ("forty-two", False, False), 62 - ("", False, True), 63 - ), 64 - ) 65 - async def test_post_is_empty(text, with_image, expected): 66 - img = Path(__file__).parent / "image.png" 67 - kwargs = {"media": []} 68 - if with_image: 69 - media = await Media.from_img(str(img)) 70 - kwargs["media"].append(media) 71 - 72 - post = Post(text, **kwargs) 73 - assert post.is_empty() is expected
-1036
uv.lock
··· 1 - version = 1 2 - revision = 3 3 - requires-python = ">=3.10, <3.15" 4 - resolution-markers = [ 5 - "python_full_version >= '3.11'", 6 - "python_full_version < '3.11'", 7 - ] 8 - 9 - [[package]] 10 - name = "aiofiles" 11 - version = "25.1.0" 12 - source = { registry = "https://pypi.org/simple" } 13 - sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } 14 - wheels = [ 15 - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, 16 - ] 17 - 18 - [[package]] 19 - name = "anyio" 20 - version = "4.4.0" 21 - source = { registry = "https://pypi.org/simple" } 22 - dependencies = [ 23 - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 24 - { name = "idna" }, 25 - { name = "sniffio" }, 26 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 27 - ] 28 - sdist = { url = "https://files.pythonhosted.org/packages/e6/e3/c4c8d473d6780ef1853d630d581f70d655b4f8d7553c6997958c283039a2/anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94", size = 163930, upload-time = "2024-05-26T22:02:15.75Z" } 29 - wheels = [ 30 - { url = "https://files.pythonhosted.org/packages/7b/a2/10639a79341f6c019dedc95bd48a4928eed9f1d1197f4c04f546fc7ae0ff/anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7", size = 86780, upload-time = "2024-05-26T22:02:13.671Z" }, 31 - ] 32 - 33 - [[package]] 34 - name = "appdirs" 35 - version = "1.4.4" 36 - source = { registry = "https://pypi.org/simple" } 37 - sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } 38 - wheels = [ 39 - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, 40 - ] 41 - 42 - [[package]] 43 - name = "asttokens" 44 - version = "2.4.1" 45 - source = { registry = "https://pypi.org/simple" } 46 - dependencies = [ 47 - { name = "six" }, 48 - ] 49 - sdist = { url = "https://files.pythonhosted.org/packages/45/1d/f03bcb60c4a3212e15f99a56085d93093a497718adf828d050b9d675da81/asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0", size = 62284, upload-time = "2023-10-26T10:03:05.06Z" } 50 - wheels = [ 51 - { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764, upload-time = "2023-10-26T10:03:01.789Z" }, 52 - ] 53 - 54 - [[package]] 55 - name = "backoff" 56 - version = "2.2.1" 57 - source = { registry = "https://pypi.org/simple" } 58 - sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } 59 - wheels = [ 60 - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, 61 - ] 62 - 63 - [[package]] 64 - name = "backports-asyncio-runner" 65 - version = "1.2.0" 66 - source = { registry = "https://pypi.org/simple" } 67 - sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } 68 - wheels = [ 69 - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, 70 - ] 71 - 72 - [[package]] 73 - name = "beautifulsoup4" 74 - version = "4.12.3" 75 - source = { registry = "https://pypi.org/simple" } 76 - dependencies = [ 77 - { name = "soupsieve" }, 78 - ] 79 - sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181, upload-time = "2024-01-17T16:53:17.902Z" } 80 - wheels = [ 81 - { url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925, upload-time = "2024-01-17T16:53:12.779Z" }, 82 - ] 83 - 84 - [[package]] 85 - name = "cachetools" 86 - version = "6.2.0" 87 - source = { registry = "https://pypi.org/simple" } 88 - sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } 89 - wheels = [ 90 - { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, 91 - ] 92 - 93 - [[package]] 94 - name = "certifi" 95 - version = "2024.8.30" 96 - source = { registry = "https://pypi.org/simple" } 97 - sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } 98 - wheels = [ 99 - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, 100 - ] 101 - 102 - [[package]] 103 - name = "cffi" 104 - version = "1.17.1" 105 - source = { registry = "https://pypi.org/simple" } 106 - dependencies = [ 107 - { name = "pycparser" }, 108 - ] 109 - sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } 110 - wheels = [ 111 - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, 112 - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, 113 - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, 114 - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, 115 - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, 116 - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, 117 - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, 118 - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, 119 - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, 120 - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, 121 - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, 122 - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, 123 - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, 124 - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, 125 - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, 126 - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, 127 - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, 128 - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, 129 - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, 130 - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, 131 - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, 132 - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, 133 - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, 134 - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, 135 - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, 136 - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, 137 - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, 138 - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, 139 - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, 140 - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, 141 - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, 142 - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, 143 - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, 144 - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, 145 - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, 146 - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, 147 - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, 148 - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, 149 - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, 150 - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, 151 - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, 152 - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, 153 - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, 154 - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, 155 - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, 156 - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, 157 - ] 158 - 159 - [[package]] 160 - name = "chardet" 161 - version = "5.2.0" 162 - source = { registry = "https://pypi.org/simple" } 163 - sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } 164 - wheels = [ 165 - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, 166 - ] 167 - 168 - [[package]] 169 - name = "click" 170 - version = "8.1.7" 171 - source = { registry = "https://pypi.org/simple" } 172 - dependencies = [ 173 - { name = "colorama", marker = "sys_platform == 'win32'" }, 174 - ] 175 - sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } 176 - wheels = [ 177 - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, 178 - ] 179 - 180 - [[package]] 181 - name = "colorama" 182 - version = "0.4.6" 183 - source = { registry = "https://pypi.org/simple" } 184 - sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 185 - wheels = [ 186 - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 187 - ] 188 - 189 - [[package]] 190 - name = "cryptography" 191 - version = "43.0.1" 192 - source = { registry = "https://pypi.org/simple" } 193 - dependencies = [ 194 - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, 195 - ] 196 - sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927, upload-time = "2024-09-03T20:04:20.788Z" } 197 - wheels = [ 198 - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222, upload-time = "2024-09-03T20:04:14.466Z" }, 199 - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751, upload-time = "2024-09-03T20:04:16.725Z" }, 200 - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827, upload-time = "2024-09-03T20:03:55.035Z" }, 201 - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034, upload-time = "2024-09-03T20:03:58.972Z" }, 202 - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407, upload-time = "2024-09-03T20:03:36.682Z" }, 203 - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457, upload-time = "2024-09-03T20:03:52.995Z" }, 204 - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499, upload-time = "2024-09-03T20:03:32.522Z" }, 205 - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504, upload-time = "2024-09-03T20:04:09.459Z" }, 206 - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456, upload-time = "2024-09-03T20:03:40.775Z" }, 207 - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263, upload-time = "2024-09-03T20:03:43.181Z" }, 208 - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368, upload-time = "2024-09-03T20:03:18.051Z" }, 209 - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750, upload-time = "2024-09-03T20:04:18.775Z" }, 210 - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925, upload-time = "2024-09-03T20:03:45.022Z" }, 211 - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152, upload-time = "2024-09-03T20:03:30.108Z" }, 212 - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392, upload-time = "2024-09-03T20:03:34.543Z" }, 213 - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606, upload-time = "2024-09-03T20:03:27.836Z" }, 214 - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948, upload-time = "2024-09-03T20:03:25.446Z" }, 215 - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445, upload-time = "2024-09-03T20:03:21.179Z" }, 216 - { url = "https://files.pythonhosted.org/packages/18/23/4175dcd935e1649865e1af7bd0b827cc9d9769a586dcc84f7cbe96839086/cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034", size = 3152694, upload-time = "2024-09-03T20:03:46.587Z" }, 217 - { url = "https://files.pythonhosted.org/packages/ea/45/967da50269954b993d4484bf85026c7377bd551651ebdabba94905972556/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d", size = 3713077, upload-time = "2024-09-03T20:04:06.586Z" }, 218 - { url = "https://files.pythonhosted.org/packages/df/e6/ccd29a1f9a6b71294e1e9f530c4d779d5dd37c8bb736c05d5fb6d98a971b/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289", size = 3915597, upload-time = "2024-09-03T20:03:38.858Z" }, 219 - { url = "https://files.pythonhosted.org/packages/a2/80/fb7d668f1be5e4443b7ac191f68390be24f7c2ebd36011741f62c7645eb2/cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84", size = 2989208, upload-time = "2024-09-03T20:04:12.261Z" }, 220 - ] 221 - 222 - [[package]] 223 - name = "decorator" 224 - version = "5.1.1" 225 - source = { registry = "https://pypi.org/simple" } 226 - sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016, upload-time = "2022-01-07T08:20:05.666Z" } 227 - wheels = [ 228 - { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073, upload-time = "2022-01-07T08:20:03.734Z" }, 229 - ] 230 - 231 - [[package]] 232 - name = "distlib" 233 - version = "0.4.0" 234 - source = { registry = "https://pypi.org/simple" } 235 - sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } 236 - wheels = [ 237 - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, 238 - ] 239 - 240 - [[package]] 241 - name = "eld" 242 - version = "1.0.6" 243 - source = { registry = "https://pypi.org/simple" } 244 - dependencies = [ 245 - { name = "regex" }, 246 - ] 247 - sdist = { url = "https://files.pythonhosted.org/packages/a5/54/eb43ee088126bde0f849446190ac7db07280b5eb52b60d57481c992ccf42/eld-1.0.6.tar.gz", hash = "sha256:68f750069cabab1294b54020bd2c7e2ce72b42779261b0446168f1f7171d97a7", size = 5254387, upload-time = "2023-09-07T22:40:47.989Z" } 248 - wheels = [ 249 - { url = "https://files.pythonhosted.org/packages/48/09/425b7c6dd560a55d7d168e040694d5ec2660edd8c48860c1d1f5f9edd525/eld-1.0.6-py3-none-any.whl", hash = "sha256:175f570537e8cdf65d48b6e3d2c14438e236d9e5ac5b5b5005d07cd8dab797b2", size = 5422842, upload-time = "2023-09-07T22:40:44.053Z" }, 250 - ] 251 - 252 - [[package]] 253 - name = "exceptiongroup" 254 - version = "1.2.2" 255 - source = { registry = "https://pypi.org/simple" } 256 - sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } 257 - wheels = [ 258 - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, 259 - ] 260 - 261 - [[package]] 262 - name = "executing" 263 - version = "2.0.1" 264 - source = { registry = "https://pypi.org/simple" } 265 - sdist = { url = "https://files.pythonhosted.org/packages/08/41/85d2d28466fca93737592b7f3cc456d1cfd6bcd401beceeba17e8e792b50/executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147", size = 836501, upload-time = "2023-10-29T10:17:13.532Z" } 266 - wheels = [ 267 - { url = "https://files.pythonhosted.org/packages/80/03/6ea8b1b2a5ab40a7a60dc464d3daa7aa546e0a74d74a9f8ff551ea7905db/executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc", size = 24922, upload-time = "2023-10-29T10:17:10.229Z" }, 268 - ] 269 - 270 - [[package]] 271 - name = "filelock" 272 - version = "3.20.0" 273 - source = { registry = "https://pypi.org/simple" } 274 - sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } 275 - wheels = [ 276 - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, 277 - ] 278 - 279 - [[package]] 280 - name = "h11" 281 - version = "0.16.0" 282 - source = { registry = "https://pypi.org/simple" } 283 - sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 284 - wheels = [ 285 - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 286 - ] 287 - 288 - [[package]] 289 - name = "httpcore" 290 - version = "1.0.9" 291 - source = { registry = "https://pypi.org/simple" } 292 - dependencies = [ 293 - { name = "certifi" }, 294 - { name = "h11" }, 295 - ] 296 - sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 297 - wheels = [ 298 - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 299 - ] 300 - 301 - [[package]] 302 - name = "httpx" 303 - version = "0.28.1" 304 - source = { registry = "https://pypi.org/simple" } 305 - dependencies = [ 306 - { name = "anyio" }, 307 - { name = "certifi" }, 308 - { name = "httpcore" }, 309 - { name = "idna" }, 310 - ] 311 - sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 312 - wheels = [ 313 - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 314 - ] 315 - 316 - [[package]] 317 - name = "idna" 318 - version = "3.8" 319 - source = { registry = "https://pypi.org/simple" } 320 - sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467, upload-time = "2024-08-23T16:01:51.339Z" } 321 - wheels = [ 322 - { url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894, upload-time = "2024-08-23T16:01:49.963Z" }, 323 - ] 324 - 325 - [[package]] 326 - name = "iniconfig" 327 - version = "2.0.0" 328 - source = { registry = "https://pypi.org/simple" } 329 - sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } 330 - wheels = [ 331 - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, 332 - ] 333 - 334 - [[package]] 335 - name = "ipdb" 336 - version = "0.13.13" 337 - source = { registry = "https://pypi.org/simple" } 338 - dependencies = [ 339 - { name = "decorator" }, 340 - { name = "ipython" }, 341 - { name = "tomli", marker = "python_full_version < '3.11'" }, 342 - ] 343 - sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042, upload-time = "2023-03-09T15:40:57.487Z" } 344 - wheels = [ 345 - { url = "https://files.pythonhosted.org/packages/0c/4c/b075da0092003d9a55cf2ecc1cae9384a1ca4f650d51b00fc59875fe76f6/ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", size = 12130, upload-time = "2023-03-09T15:40:55.021Z" }, 346 - ] 347 - 348 - [[package]] 349 - name = "ipython" 350 - version = "8.18.1" 351 - source = { registry = "https://pypi.org/simple" } 352 - dependencies = [ 353 - { name = "colorama", marker = "sys_platform == 'win32'" }, 354 - { name = "decorator" }, 355 - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 356 - { name = "jedi" }, 357 - { name = "matplotlib-inline" }, 358 - { name = "pexpect", marker = "sys_platform != 'win32'" }, 359 - { name = "prompt-toolkit" }, 360 - { name = "pygments" }, 361 - { name = "stack-data" }, 362 - { name = "traitlets" }, 363 - ] 364 - sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330, upload-time = "2023-11-27T09:58:34.596Z" } 365 - wheels = [ 366 - { url = "https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397", size = 808161, upload-time = "2023-11-27T09:58:30.538Z" }, 367 - ] 368 - 369 - [[package]] 370 - name = "jedi" 371 - version = "0.19.1" 372 - source = { registry = "https://pypi.org/simple" } 373 - dependencies = [ 374 - { name = "parso" }, 375 - ] 376 - sdist = { url = "https://files.pythonhosted.org/packages/d6/99/99b493cec4bf43176b678de30f81ed003fd6a647a301b9c927280c600f0a/jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", size = 1227821, upload-time = "2023-10-02T09:20:39.728Z" } 377 - wheels = [ 378 - { url = "https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0", size = 1569361, upload-time = "2023-10-02T09:20:35.754Z" }, 379 - ] 380 - 381 - [[package]] 382 - name = "markdown-it-py" 383 - version = "3.0.0" 384 - source = { registry = "https://pypi.org/simple" } 385 - dependencies = [ 386 - { name = "mdurl" }, 387 - ] 388 - sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } 389 - wheels = [ 390 - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, 391 - ] 392 - 393 - [[package]] 394 - name = "matplotlib-inline" 395 - version = "0.1.7" 396 - source = { registry = "https://pypi.org/simple" } 397 - dependencies = [ 398 - { name = "traitlets" }, 399 - ] 400 - sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } 401 - wheels = [ 402 - { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, 403 - ] 404 - 405 - [[package]] 406 - name = "mdurl" 407 - version = "0.1.2" 408 - source = { registry = "https://pypi.org/simple" } 409 - sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 410 - wheels = [ 411 - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 412 - ] 413 - 414 - [[package]] 415 - name = "mypy" 416 - version = "1.11.2" 417 - source = { registry = "https://pypi.org/simple" } 418 - dependencies = [ 419 - { name = "mypy-extensions" }, 420 - { name = "tomli", marker = "python_full_version < '3.11'" }, 421 - { name = "typing-extensions", version = "4.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, 422 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 423 - ] 424 - sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806, upload-time = "2024-08-24T22:50:11.357Z" } 425 - wheels = [ 426 - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401, upload-time = "2024-08-24T22:49:18.929Z" }, 427 - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697, upload-time = "2024-08-24T22:49:32.504Z" }, 428 - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508, upload-time = "2024-08-24T22:49:12.327Z" }, 429 - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712, upload-time = "2024-08-24T22:49:49.399Z" }, 430 - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319, upload-time = "2024-08-24T22:49:26.88Z" }, 431 - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630, upload-time = "2024-08-24T22:49:51.895Z" }, 432 - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973, upload-time = "2024-08-24T22:49:21.428Z" }, 433 - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659, upload-time = "2024-08-24T22:49:35.02Z" }, 434 - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010, upload-time = "2024-08-24T22:49:29.725Z" }, 435 - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873, upload-time = "2024-08-24T22:49:40.448Z" }, 436 - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335, upload-time = "2024-08-24T22:49:54.245Z" }, 437 - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119, upload-time = "2024-08-24T22:49:03.451Z" }, 438 - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856, upload-time = "2024-08-24T22:50:08.804Z" }, 439 - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066, upload-time = "2024-08-24T22:50:03.89Z" }, 440 - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000, upload-time = "2024-08-24T22:49:59.703Z" }, 441 - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625, upload-time = "2024-08-24T22:50:01.842Z" }, 442 - ] 443 - 444 - [[package]] 445 - name = "mypy-extensions" 446 - version = "1.0.0" 447 - source = { registry = "https://pypi.org/simple" } 448 - sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } 449 - wheels = [ 450 - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, 451 - ] 452 - 453 - [[package]] 454 - name = "not-my-ex" 455 - version = "1.2.1" 456 - source = { editable = "." } 457 - dependencies = [ 458 - { name = "aiofiles" }, 459 - { name = "appdirs" }, 460 - { name = "backoff" }, 461 - { name = "beautifulsoup4" }, 462 - { name = "cryptography" }, 463 - { name = "eld" }, 464 - { name = "httpx" }, 465 - { name = "typer" }, 466 - ] 467 - 468 - [package.dev-dependencies] 469 - dev = [ 470 - { name = "ipdb" }, 471 - { name = "pytest-asyncio" }, 472 - { name = "pytest-mypy" }, 473 - { name = "pytest-ruff" }, 474 - { name = "tox-uv" }, 475 - { name = "types-aiofiles" }, 476 - { name = "types-appdirs" }, 477 - { name = "types-beautifulsoup4" }, 478 - ] 479 - 480 - [package.metadata] 481 - requires-dist = [ 482 - { name = "aiofiles", specifier = ">=25.1.0" }, 483 - { name = "appdirs", specifier = ">=1.4.4" }, 484 - { name = "backoff", specifier = ">=2.2.1" }, 485 - { name = "beautifulsoup4", specifier = ">=4.12.3" }, 486 - { name = "cryptography", specifier = ">=43.0.1" }, 487 - { name = "eld", specifier = ">=1.0.6" }, 488 - { name = "httpx", specifier = ">=0.28.1" }, 489 - { name = "typer", specifier = ">=0.12.5" }, 490 - ] 491 - 492 - [package.metadata.requires-dev] 493 - dev = [ 494 - { name = "ipdb", specifier = "==0.13.13" }, 495 - { name = "pytest-asyncio", specifier = "==1.3.0" }, 496 - { name = "pytest-mypy", specifier = "==1.0.1" }, 497 - { name = "pytest-ruff", specifier = "==0.5" }, 498 - { name = "tox-uv", specifier = "==1.29.0" }, 499 - { name = "types-aiofiles", specifier = "==25.1.0.20251011" }, 500 - { name = "types-appdirs", specifier = "==1.4.3.5" }, 501 - { name = "types-beautifulsoup4", specifier = "==4.12.0.20250516" }, 502 - ] 503 - 504 - [[package]] 505 - name = "packaging" 506 - version = "25.0" 507 - source = { registry = "https://pypi.org/simple" } 508 - sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 509 - wheels = [ 510 - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 511 - ] 512 - 513 - [[package]] 514 - name = "parso" 515 - version = "0.8.4" 516 - source = { registry = "https://pypi.org/simple" } 517 - sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } 518 - wheels = [ 519 - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, 520 - ] 521 - 522 - [[package]] 523 - name = "pexpect" 524 - version = "4.9.0" 525 - source = { registry = "https://pypi.org/simple" } 526 - dependencies = [ 527 - { name = "ptyprocess" }, 528 - ] 529 - sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } 530 - wheels = [ 531 - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, 532 - ] 533 - 534 - [[package]] 535 - name = "platformdirs" 536 - version = "4.5.0" 537 - source = { registry = "https://pypi.org/simple" } 538 - sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } 539 - wheels = [ 540 - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, 541 - ] 542 - 543 - [[package]] 544 - name = "pluggy" 545 - version = "1.6.0" 546 - source = { registry = "https://pypi.org/simple" } 547 - sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 548 - wheels = [ 549 - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 550 - ] 551 - 552 - [[package]] 553 - name = "prompt-toolkit" 554 - version = "3.0.47" 555 - source = { registry = "https://pypi.org/simple" } 556 - dependencies = [ 557 - { name = "wcwidth" }, 558 - ] 559 - sdist = { url = "https://files.pythonhosted.org/packages/47/6d/0279b119dafc74c1220420028d490c4399b790fc1256998666e3a341879f/prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360", size = 425859, upload-time = "2024-06-10T11:02:14.045Z" } 560 - wheels = [ 561 - { url = "https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", size = 386411, upload-time = "2024-06-10T11:02:10.477Z" }, 562 - ] 563 - 564 - [[package]] 565 - name = "ptyprocess" 566 - version = "0.7.0" 567 - source = { registry = "https://pypi.org/simple" } 568 - sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } 569 - wheels = [ 570 - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, 571 - ] 572 - 573 - [[package]] 574 - name = "pure-eval" 575 - version = "0.2.3" 576 - source = { registry = "https://pypi.org/simple" } 577 - sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } 578 - wheels = [ 579 - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, 580 - ] 581 - 582 - [[package]] 583 - name = "pycparser" 584 - version = "2.22" 585 - source = { registry = "https://pypi.org/simple" } 586 - sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } 587 - wheels = [ 588 - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, 589 - ] 590 - 591 - [[package]] 592 - name = "pygments" 593 - version = "2.18.0" 594 - source = { registry = "https://pypi.org/simple" } 595 - sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } 596 - wheels = [ 597 - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, 598 - ] 599 - 600 - [[package]] 601 - name = "pyproject-api" 602 - version = "1.9.1" 603 - source = { registry = "https://pypi.org/simple" } 604 - dependencies = [ 605 - { name = "packaging" }, 606 - { name = "tomli", marker = "python_full_version < '3.11'" }, 607 - ] 608 - sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } 609 - wheels = [ 610 - { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, 611 - ] 612 - 613 - [[package]] 614 - name = "pytest" 615 - version = "8.3.2" 616 - source = { registry = "https://pypi.org/simple" } 617 - dependencies = [ 618 - { name = "colorama", marker = "sys_platform == 'win32'" }, 619 - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 620 - { name = "iniconfig" }, 621 - { name = "packaging" }, 622 - { name = "pluggy" }, 623 - { name = "tomli", marker = "python_full_version < '3.11'" }, 624 - ] 625 - sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314, upload-time = "2024-07-25T10:40:00.159Z" } 626 - wheels = [ 627 - { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802, upload-time = "2024-07-25T10:39:57.834Z" }, 628 - ] 629 - 630 - [[package]] 631 - name = "pytest-asyncio" 632 - version = "1.3.0" 633 - source = { registry = "https://pypi.org/simple" } 634 - dependencies = [ 635 - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, 636 - { name = "pytest" }, 637 - { name = "typing-extensions", version = "4.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, 638 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 639 - ] 640 - sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } 641 - wheels = [ 642 - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, 643 - ] 644 - 645 - [[package]] 646 - name = "pytest-mypy" 647 - version = "1.0.1" 648 - source = { registry = "https://pypi.org/simple" } 649 - dependencies = [ 650 - { name = "filelock" }, 651 - { name = "mypy" }, 652 - { name = "pytest" }, 653 - ] 654 - sdist = { url = "https://files.pythonhosted.org/packages/b0/50/3ce149b469e27848c1dc354553b17774f9dde0140625f5a4130bd21e1052/pytest_mypy-1.0.1.tar.gz", hash = "sha256:3f5fcaff75c80dccc6b68cf5ecc28e1bbe71e95309469eb7a28bf408ce55c074", size = 15975, upload-time = "2025-04-02T19:31:16.151Z" } 655 - wheels = [ 656 - { url = "https://files.pythonhosted.org/packages/bf/93/25ed3c02e15c4ef1b04cbda7c708ffc5da755986aaacfb48db1f9e84a996/pytest_mypy-1.0.1-py3-none-any.whl", hash = "sha256:ad7133c9b92c802e032f2596590ebede7eea7c418e61d60d5cdd571b55c72056", size = 8701, upload-time = "2025-04-02T19:31:14.914Z" }, 657 - ] 658 - 659 - [[package]] 660 - name = "pytest-ruff" 661 - version = "0.5" 662 - source = { registry = "https://pypi.org/simple" } 663 - dependencies = [ 664 - { name = "pytest" }, 665 - { name = "ruff" }, 666 - ] 667 - sdist = { url = "https://files.pythonhosted.org/packages/32/5a/bee55130757fb4a14e0ac5d2c6558323275b5dccfa2f5353f3893299ff7d/pytest_ruff-0.5.tar.gz", hash = "sha256:f611c780fc2b9b8d7041fa0e7589f0a9f352b288d0cfc330881101b35d382063", size = 3854, upload-time = "2025-06-19T07:26:24.607Z" } 668 - wheels = [ 669 - { url = "https://files.pythonhosted.org/packages/1d/ca/abfce6de0bb0f017ed05ef9f2330596235cc5a3341b4d8d40895682b3814/pytest_ruff-0.5-py3-none-any.whl", hash = "sha256:d9db170d86fb167008e6702b4d79e2cccd8287f069c3a57f9261831cebdc4a31", size = 4680, upload-time = "2025-06-19T07:26:23.897Z" }, 670 - ] 671 - 672 - [[package]] 673 - name = "regex" 674 - version = "2024.7.24" 675 - source = { registry = "https://pypi.org/simple" } 676 - sdist = { url = "https://files.pythonhosted.org/packages/3f/51/64256d0dc72816a4fe3779449627c69ec8fee5a5625fd60ba048f53b3478/regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506", size = 393485, upload-time = "2024-07-24T21:51:16.135Z" } 677 - wheels = [ 678 - { url = "https://files.pythonhosted.org/packages/16/97/283bd32777e6c30a9bede976cd72ba4b9aa144dc0f0f462bd37fa1a86e01/regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce", size = 470812, upload-time = "2024-07-24T21:46:28.015Z" }, 679 - { url = "https://files.pythonhosted.org/packages/e4/80/80bc4d7329d04ba519ebcaf26ae21d9e30d33934c458691177c623ceff70/regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024", size = 282129, upload-time = "2024-07-24T21:46:33.941Z" }, 680 - { url = "https://files.pythonhosted.org/packages/e5/8a/cddcb7942d05ad9a427ad97ab29f1a62c0607ab72bdb2f3a26fc5b07ac0f/regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd", size = 278909, upload-time = "2024-07-24T21:46:37.547Z" }, 681 - { url = "https://files.pythonhosted.org/packages/a6/d4/93b4011cb83f9a66e0fa398b4d3c6d564d94b686dace676c66502b13dae9/regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53", size = 777687, upload-time = "2024-07-24T21:46:41.345Z" }, 682 - { url = "https://files.pythonhosted.org/packages/d0/11/d0a12e1cecc1d35bbcbeb99e2ddcb8c1b152b1b58e2ff55f50c3d762b09e/regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca", size = 818982, upload-time = "2024-07-24T21:46:44.37Z" }, 683 - { url = "https://files.pythonhosted.org/packages/ae/41/01a073765d75427e24710af035d8f0a773b5cedf23f61b63e7ef2ce960d6/regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59", size = 804015, upload-time = "2024-07-24T21:46:47.815Z" }, 684 - { url = "https://files.pythonhosted.org/packages/3e/66/04b63f31580026c8b819aed7f171149177d10cfab27477ea8800a2268d50/regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41", size = 776517, upload-time = "2024-07-24T21:46:50.561Z" }, 685 - { url = "https://files.pythonhosted.org/packages/be/49/0c08a7a232e4e26e17afeedf13f331224d9377dde4876ed6e21e4a584a5d/regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5", size = 766860, upload-time = "2024-07-24T21:46:53.707Z" }, 686 - { url = "https://files.pythonhosted.org/packages/24/44/35769388845cdd7be97e1232a59446b738054b61bc9c92a3b0bacfaf7bb1/regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46", size = 692181, upload-time = "2024-07-24T21:46:56.788Z" }, 687 - { url = "https://files.pythonhosted.org/packages/50/be/4e09d5bc8de176153f209c95ca4e64b9def1748d693694a95dd4401ee7be/regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f", size = 762956, upload-time = "2024-07-24T21:47:00.201Z" }, 688 - { url = "https://files.pythonhosted.org/packages/90/63/b37152f25fe348aa31806bafa91df607d096e8f477fed9a5cf3de339dd5f/regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7", size = 771978, upload-time = "2024-07-24T21:47:03.279Z" }, 689 - { url = "https://files.pythonhosted.org/packages/ab/ac/38186431f7c1874e3f790669be933accf1090ee53aba0ab1a811ef38f07e/regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe", size = 840800, upload-time = "2024-07-24T21:47:06.218Z" }, 690 - { url = "https://files.pythonhosted.org/packages/e8/23/91b04dbf51a2c0ddf5b1e055e9e05ed091ebcf46f2b0e6e3d2fff121f903/regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce", size = 838991, upload-time = "2024-07-24T21:47:09.554Z" }, 691 - { url = "https://files.pythonhosted.org/packages/36/fd/822110cc14b99bdd7d8c61487bc774f454120cd3d7492935bf13f3399716/regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa", size = 767539, upload-time = "2024-07-24T21:47:12.562Z" }, 692 - { url = "https://files.pythonhosted.org/packages/82/54/e24a8adfca74f9a421cd47657c51413919e7755e729608de6f4c5556e002/regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66", size = 257712, upload-time = "2024-07-24T21:47:15.777Z" }, 693 - { url = "https://files.pythonhosted.org/packages/fb/cc/6485c2fc72d0de9b55392246b80921639f1be62bed1e33e982940306b5ba/regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e", size = 269661, upload-time = "2024-07-24T21:47:19.69Z" }, 694 - { url = "https://files.pythonhosted.org/packages/cb/ec/261f8434a47685d61e59a4ef3d9ce7902af521219f3ebd2194c7adb171a6/regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281", size = 470810, upload-time = "2024-07-24T21:47:22.913Z" }, 695 - { url = "https://files.pythonhosted.org/packages/f0/47/f33b1cac88841f95fff862476a9e875d9a10dae6912a675c6f13c128e5d9/regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b", size = 282126, upload-time = "2024-07-24T21:47:25.792Z" }, 696 - { url = "https://files.pythonhosted.org/packages/fc/1b/256ca4e2d5041c0aa2f1dc222f04412b796346ab9ce2aa5147405a9457b4/regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a", size = 278920, upload-time = "2024-07-24T21:47:28.47Z" }, 697 - { url = "https://files.pythonhosted.org/packages/91/03/4603ec057c0bafd2f6f50b0bdda4b12a0ff81022decf1de007b485c356a6/regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73", size = 785420, upload-time = "2024-07-24T21:47:31.075Z" }, 698 - { url = "https://files.pythonhosted.org/packages/75/f8/13b111fab93e6273e26de2926345e5ecf6ddad1e44c4d419d7b0924f9c52/regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2", size = 828164, upload-time = "2024-07-24T21:47:33.857Z" }, 699 - { url = "https://files.pythonhosted.org/packages/4a/80/bc3b9d31bd47ff578758af929af0ac1d6169b247e26fa6e87764007f3d93/regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e", size = 812621, upload-time = "2024-07-24T21:47:37.658Z" }, 700 - { url = "https://files.pythonhosted.org/packages/8b/77/92d4a14530900d46dddc57b728eea65d723cc9fcfd07b96c2c141dabba84/regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51", size = 786609, upload-time = "2024-07-24T21:47:40.483Z" }, 701 - { url = "https://files.pythonhosted.org/packages/35/58/06695fd8afad4c8ed0a53ec5e222156398b9fe5afd58887ab94ea68e4d16/regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364", size = 775290, upload-time = "2024-07-24T21:47:43.792Z" }, 702 - { url = "https://files.pythonhosted.org/packages/1b/0f/50b97ee1fc6965744b9e943b5c0f3740792ab54792df73d984510964ef29/regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee", size = 772849, upload-time = "2024-07-24T21:47:47.04Z" }, 703 - { url = "https://files.pythonhosted.org/packages/8f/64/565ff6cf241586ab7ae76bb4138c4d29bc1d1780973b457c2db30b21809a/regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c", size = 778428, upload-time = "2024-07-24T21:47:50.032Z" }, 704 - { url = "https://files.pythonhosted.org/packages/e5/fe/4ceabf4382e44e1e096ac46fd5e3bca490738b24157116a48270fd542e88/regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce", size = 849436, upload-time = "2024-07-24T21:47:53.446Z" }, 705 - { url = "https://files.pythonhosted.org/packages/68/23/1868e40d6b594843fd1a3498ffe75d58674edfc90d95e18dd87865b93bf2/regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1", size = 849484, upload-time = "2024-07-24T21:47:56.969Z" }, 706 - { url = "https://files.pythonhosted.org/packages/f3/52/bff76de2f6e2bc05edce3abeb7e98e6309aa022fc06071100a0216fbeb50/regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e", size = 776712, upload-time = "2024-07-24T21:47:59.876Z" }, 707 - { url = "https://files.pythonhosted.org/packages/f2/72/70ade7b0b5fe5c6df38fdfa2a5a8273e3ea6a10b772aa671b7e889e78bae/regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c", size = 257716, upload-time = "2024-07-24T21:48:03.094Z" }, 708 - { url = "https://files.pythonhosted.org/packages/04/4d/80e04f4e27ab0cbc9096e2d10696da6d9c26a39b60db52670fd57614fea5/regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52", size = 269662, upload-time = "2024-07-24T21:48:06.441Z" }, 709 - { url = "https://files.pythonhosted.org/packages/0f/26/f505782f386ac0399a9237571833f187414882ab6902e2e71a1ecb506835/regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86", size = 471748, upload-time = "2024-07-24T21:48:08.949Z" }, 710 - { url = "https://files.pythonhosted.org/packages/bb/1d/ea9a21beeb433dbfca31ab82867d69cb67ff8674af9fab6ebd55fa9d3387/regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad", size = 282841, upload-time = "2024-07-24T21:48:12.298Z" }, 711 - { url = "https://files.pythonhosted.org/packages/9b/f2/c6182095baf0a10169c34e87133a8e73b2e816a80035669b1278e927685e/regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9", size = 279114, upload-time = "2024-07-24T21:48:15.743Z" }, 712 - { url = "https://files.pythonhosted.org/packages/72/58/b5161bf890b6ca575a25685f19a4a3e3b6f4a072238814f8658123177d84/regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289", size = 789749, upload-time = "2024-07-24T21:48:18.902Z" }, 713 - { url = "https://files.pythonhosted.org/packages/09/fb/5381b19b62f3a3494266be462f6a015a869cf4bfd8e14d6e7db67e2c8069/regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9", size = 831666, upload-time = "2024-07-24T21:48:21.518Z" }, 714 - { url = "https://files.pythonhosted.org/packages/3d/6d/2a21c85f970f9be79357d12cf4b97f4fc6bf3bf6b843c39dabbc4e5f1181/regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c", size = 817544, upload-time = "2024-07-24T21:48:24.319Z" }, 715 - { url = "https://files.pythonhosted.org/packages/f9/ae/5f23e64f6cf170614237c654f3501a912dfb8549143d4b91d1cd13dba319/regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440", size = 790854, upload-time = "2024-07-24T21:48:27.301Z" }, 716 - { url = "https://files.pythonhosted.org/packages/29/0a/d04baad1bbc49cdfb4aef90c4fc875a60aaf96d35a1616f1dfe8149716bc/regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610", size = 779242, upload-time = "2024-07-24T21:48:30.747Z" }, 717 - { url = "https://files.pythonhosted.org/packages/3a/27/b242a962f650c3213da4596d70e24c7c1c46e3aa0f79f2a81164291085f8/regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5", size = 776932, upload-time = "2024-07-24T21:48:33.577Z" }, 718 - { url = "https://files.pythonhosted.org/packages/9c/ae/de659bdfff80ad2c0b577a43dd89dbc43870a4fc4bbf604e452196758e83/regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799", size = 784521, upload-time = "2024-07-24T21:48:38.169Z" }, 719 - { url = "https://files.pythonhosted.org/packages/d4/ac/eb6a796da0bdefbf09644a7868309423b18d344cf49963a9d36c13502d46/regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05", size = 854548, upload-time = "2024-07-24T21:48:42.858Z" }, 720 - { url = "https://files.pythonhosted.org/packages/56/77/fde8d825dec69e70256e0925af6c81eea9acf0a634d3d80f619d8dcd6888/regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94", size = 853345, upload-time = "2024-07-24T21:48:46.349Z" }, 721 - { url = "https://files.pythonhosted.org/packages/ff/04/2b79ad0bb9bc05ab4386caa2c19aa047a66afcbdfc2640618ffc729841e4/regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38", size = 781414, upload-time = "2024-07-24T21:48:51.665Z" }, 722 - { url = "https://files.pythonhosted.org/packages/bf/71/d0af58199283ada7d25b20e416f5b155f50aad99b0e791c0966ff5a1cd00/regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc", size = 258125, upload-time = "2024-07-24T21:48:55.989Z" }, 723 - { url = "https://files.pythonhosted.org/packages/95/b3/10e875c45c60b010b66fc109b899c6fc4f05d485fe1d54abff98ce791124/regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908", size = 269162, upload-time = "2024-07-24T21:49:01.04Z" }, 724 - ] 725 - 726 - [[package]] 727 - name = "rich" 728 - version = "13.8.0" 729 - source = { registry = "https://pypi.org/simple" } 730 - dependencies = [ 731 - { name = "markdown-it-py" }, 732 - { name = "pygments" }, 733 - ] 734 - sdist = { url = "https://files.pythonhosted.org/packages/cf/60/5959113cae0ce512cf246a6871c623117330105a0d5f59b4e26138f2c9cc/rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4", size = 222072, upload-time = "2024-08-26T16:12:27.073Z" } 735 - wheels = [ 736 - { url = "https://files.pythonhosted.org/packages/c7/d9/c2a126eeae791e90ea099d05cb0515feea3688474b978343f3cdcfe04523/rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc", size = 241597, upload-time = "2024-08-26T16:12:25.449Z" }, 737 - ] 738 - 739 - [[package]] 740 - name = "ruff" 741 - version = "0.6.3" 742 - source = { registry = "https://pypi.org/simple" } 743 - sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514, upload-time = "2024-08-29T15:16:41.015Z" } 744 - wheels = [ 745 - { url = "https://files.pythonhosted.org/packages/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928, upload-time = "2024-08-29T15:15:54.085Z" }, 746 - { url = "https://files.pythonhosted.org/packages/6e/59/3b8b1d3a4271c6eb6ceecd3cef19a6d881639a0f18ad651563d6f619aaae/ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc", size = 9448462, upload-time = "2024-08-29T15:15:57.177Z" }, 747 - { url = "https://files.pythonhosted.org/packages/35/4f/b942ecb8bbebe53aa9b33e9b96df88acd50b70adaaed3070f1d92131a1cb/ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1", size = 9176190, upload-time = "2024-08-29T15:15:59.568Z" }, 748 - { url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892, upload-time = "2024-08-29T15:16:01.885Z" }, 749 - { url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471, upload-time = "2024-08-29T15:16:04.397Z" }, 750 - { url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802, upload-time = "2024-08-29T15:16:07.278Z" }, 751 - { url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372, upload-time = "2024-08-29T15:16:10.014Z" }, 752 - { url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596, upload-time = "2024-08-29T15:16:12.99Z" }, 753 - { url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830, upload-time = "2024-08-29T15:16:15.479Z" }, 754 - { url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577, upload-time = "2024-08-29T15:16:18.593Z" }, 755 - { url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751, upload-time = "2024-08-29T15:16:21.862Z" }, 756 - { url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859, upload-time = "2024-08-29T15:16:24.806Z" }, 757 - { url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291, upload-time = "2024-08-29T15:16:27.894Z" }, 758 - { url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549, upload-time = "2024-08-29T15:16:31.045Z" }, 759 - { url = "https://files.pythonhosted.org/packages/b4/73/ca9c2f9237a430ca423b6dca83b77e9a428afeb7aec80596e86c369123fe/ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521", size = 7962163, upload-time = "2024-08-29T15:16:33.78Z" }, 760 - { url = "https://files.pythonhosted.org/packages/55/ce/061c605b1dfb52748d59bc0c7a8507546c178801156415773d18febfd71d/ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb", size = 8800901, upload-time = "2024-08-29T15:16:36.498Z" }, 761 - { url = "https://files.pythonhosted.org/packages/63/28/ae4ffe7d3b6134ca6d31ebef07447ef70097c4a9e8fbbc519b374c5c1559/ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82", size = 8229171, upload-time = "2024-08-29T15:16:39.281Z" }, 762 - ] 763 - 764 - [[package]] 765 - name = "shellingham" 766 - version = "1.5.4" 767 - source = { registry = "https://pypi.org/simple" } 768 - sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } 769 - wheels = [ 770 - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, 771 - ] 772 - 773 - [[package]] 774 - name = "six" 775 - version = "1.16.0" 776 - source = { registry = "https://pypi.org/simple" } 777 - sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } 778 - wheels = [ 779 - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, 780 - ] 781 - 782 - [[package]] 783 - name = "sniffio" 784 - version = "1.3.1" 785 - source = { registry = "https://pypi.org/simple" } 786 - sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } 787 - wheels = [ 788 - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, 789 - ] 790 - 791 - [[package]] 792 - name = "soupsieve" 793 - version = "2.6" 794 - source = { registry = "https://pypi.org/simple" } 795 - sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569, upload-time = "2024-08-13T13:39:12.166Z" } 796 - wheels = [ 797 - { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186, upload-time = "2024-08-13T13:39:10.986Z" }, 798 - ] 799 - 800 - [[package]] 801 - name = "stack-data" 802 - version = "0.6.3" 803 - source = { registry = "https://pypi.org/simple" } 804 - dependencies = [ 805 - { name = "asttokens" }, 806 - { name = "executing" }, 807 - { name = "pure-eval" }, 808 - ] 809 - sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } 810 - wheels = [ 811 - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, 812 - ] 813 - 814 - [[package]] 815 - name = "tomli" 816 - version = "2.3.0" 817 - source = { registry = "https://pypi.org/simple" } 818 - sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } 819 - wheels = [ 820 - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, 821 - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, 822 - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, 823 - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, 824 - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, 825 - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, 826 - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, 827 - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, 828 - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, 829 - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, 830 - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, 831 - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, 832 - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, 833 - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, 834 - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, 835 - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, 836 - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, 837 - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, 838 - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, 839 - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, 840 - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, 841 - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, 842 - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, 843 - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, 844 - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, 845 - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, 846 - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, 847 - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, 848 - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, 849 - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, 850 - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, 851 - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, 852 - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, 853 - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, 854 - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, 855 - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, 856 - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, 857 - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, 858 - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, 859 - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, 860 - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, 861 - ] 862 - 863 - [[package]] 864 - name = "tox" 865 - version = "4.32.0" 866 - source = { registry = "https://pypi.org/simple" } 867 - dependencies = [ 868 - { name = "cachetools" }, 869 - { name = "chardet" }, 870 - { name = "colorama" }, 871 - { name = "filelock" }, 872 - { name = "packaging" }, 873 - { name = "platformdirs" }, 874 - { name = "pluggy" }, 875 - { name = "pyproject-api" }, 876 - { name = "tomli", marker = "python_full_version < '3.11'" }, 877 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 878 - { name = "virtualenv" }, 879 - ] 880 - sdist = { url = "https://files.pythonhosted.org/packages/59/bf/0e4dbd42724cbae25959f0e34c95d0c730df03ab03f54d52accd9abfc614/tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", size = 203330, upload-time = "2025-10-24T18:03:38.132Z" } 881 - wheels = [ 882 - { url = "https://files.pythonhosted.org/packages/fc/cc/e09c0d663a004945f82beecd4f147053567910479314e8d01ba71e5d5dea/tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551", size = 175905, upload-time = "2025-10-24T18:03:36.337Z" }, 883 - ] 884 - 885 - [[package]] 886 - name = "tox-uv" 887 - version = "1.29.0" 888 - source = { registry = "https://pypi.org/simple" } 889 - dependencies = [ 890 - { name = "packaging" }, 891 - { name = "tomli", marker = "python_full_version < '3.11'" }, 892 - { name = "tox" }, 893 - { name = "uv" }, 894 - ] 895 - sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" } 896 - wheels = [ 897 - { url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" }, 898 - ] 899 - 900 - [[package]] 901 - name = "traitlets" 902 - version = "5.14.3" 903 - source = { registry = "https://pypi.org/simple" } 904 - sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } 905 - wheels = [ 906 - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, 907 - ] 908 - 909 - [[package]] 910 - name = "typer" 911 - version = "0.12.5" 912 - source = { registry = "https://pypi.org/simple" } 913 - dependencies = [ 914 - { name = "click" }, 915 - { name = "rich" }, 916 - { name = "shellingham" }, 917 - { name = "typing-extensions", version = "4.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, 918 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 919 - ] 920 - sdist = { url = "https://files.pythonhosted.org/packages/c5/58/a79003b91ac2c6890fc5d90145c662fd5771c6f11447f116b63300436bc9/typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722", size = 98953, upload-time = "2024-08-24T21:17:57.346Z" } 921 - wheels = [ 922 - { url = "https://files.pythonhosted.org/packages/a8/2b/886d13e742e514f704c33c4caa7df0f3b89e5a25ef8db02aa9ca3d9535d5/typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b", size = 47288, upload-time = "2024-08-24T21:17:55.451Z" }, 923 - ] 924 - 925 - [[package]] 926 - name = "types-aiofiles" 927 - version = "25.1.0.20251011" 928 - source = { registry = "https://pypi.org/simple" } 929 - sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" } 930 - wheels = [ 931 - { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, 932 - ] 933 - 934 - [[package]] 935 - name = "types-appdirs" 936 - version = "1.4.3.5" 937 - source = { registry = "https://pypi.org/simple" } 938 - sdist = { url = "https://files.pythonhosted.org/packages/fe/dc/600964f9ee98f4afdb69be74cd8e1ca566635a76ada9af0046e44a778fbb/types-appdirs-1.4.3.5.tar.gz", hash = "sha256:83268da64585361bfa291f8f506a209276212a0497bd37f0512a939b3d69ff14", size = 2866, upload-time = "2023-03-14T15:21:34.849Z" } 939 - wheels = [ 940 - { url = "https://files.pythonhosted.org/packages/cf/07/41f5b9b11f11855eb67760ed680330e0ce9136a44b51c24dd52edb1c4eb1/types_appdirs-1.4.3.5-py3-none-any.whl", hash = "sha256:337c750e423c40911d389359b4edabe5bbc2cdd5cd0bd0518b71d2839646273b", size = 2667, upload-time = "2023-03-14T15:21:32.431Z" }, 941 - ] 942 - 943 - [[package]] 944 - name = "types-beautifulsoup4" 945 - version = "4.12.0.20250516" 946 - source = { registry = "https://pypi.org/simple" } 947 - dependencies = [ 948 - { name = "types-html5lib" }, 949 - ] 950 - sdist = { url = "https://files.pythonhosted.org/packages/6d/d1/32b410f6d65eda94d3dfb0b3d0ca151f12cb1dc4cef731dcf7cbfd8716ff/types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e", size = 16628, upload-time = "2025-05-16T03:09:09.93Z" } 951 - wheels = [ 952 - { url = "https://files.pythonhosted.org/packages/7c/79/d84de200a80085b32f12c5820d4fd0addcbe7ba6dce8c1c9d8605e833c8e/types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee", size = 16879, upload-time = "2025-05-16T03:09:09.051Z" }, 953 - ] 954 - 955 - [[package]] 956 - name = "types-html5lib" 957 - version = "1.1.11.20240806" 958 - source = { registry = "https://pypi.org/simple" } 959 - sdist = { url = "https://files.pythonhosted.org/packages/21/ac/a2ca5366f0337ae9c947d611c19116bd56e845976782aaa35247e2a699e8/types-html5lib-1.1.11.20240806.tar.gz", hash = "sha256:8060dc98baf63d6796a765bbbc809fff9f7a383f6e3a9add526f814c086545ef", size = 11269, upload-time = "2024-08-06T02:29:45.046Z" } 960 - wheels = [ 961 - { url = "https://files.pythonhosted.org/packages/d9/df/ee52df5c2cb7f40f6b9d45fc11cc9256d3e237e04d57e2d797b448815fc7/types_html5lib-1.1.11.20240806-py3-none-any.whl", hash = "sha256:575c4fd84ba8eeeaa8520c7e4c7042b7791f5ec3e9c0a5d5c418124c42d9e7e4", size = 17260, upload-time = "2024-08-06T02:29:43.391Z" }, 962 - ] 963 - 964 - [[package]] 965 - name = "typing-extensions" 966 - version = "4.12.2" 967 - source = { registry = "https://pypi.org/simple" } 968 - resolution-markers = [ 969 - "python_full_version >= '3.11'", 970 - ] 971 - sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } 972 - wheels = [ 973 - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, 974 - ] 975 - 976 - [[package]] 977 - name = "typing-extensions" 978 - version = "4.15.0" 979 - source = { registry = "https://pypi.org/simple" } 980 - resolution-markers = [ 981 - "python_full_version < '3.11'", 982 - ] 983 - sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 984 - wheels = [ 985 - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 986 - ] 987 - 988 - [[package]] 989 - name = "uv" 990 - version = "0.9.13" 991 - source = { registry = "https://pypi.org/simple" } 992 - sdist = { url = "https://files.pythonhosted.org/packages/f2/10/ad3dc22d0cabe7c335a1d7fc079ceda73236c0984da8d8446de3d2d30c9b/uv-0.9.13.tar.gz", hash = "sha256:105a6f4ff91480425d1b61917e89ac5635b8e58a79267e2be103338ab448ccd6", size = 3761269, upload-time = "2025-11-26T16:17:30.036Z" } 993 - wheels = [ 994 - { url = "https://files.pythonhosted.org/packages/6a/ae/94ec7111b006bc7212bf727907a35510a37928c15302ecc3757cfd7d6d7f/uv-0.9.13-py3-none-linux_armv6l.whl", hash = "sha256:7be41bdeb82c246f8ef1421cf4d1dd6ab3e5f46e4235eb22c8f5bf095debc069", size = 20830010, upload-time = "2025-11-26T16:17:13.147Z" }, 995 - { url = "https://files.pythonhosted.org/packages/8a/53/5eb0eb0ca7ed41c10447d6c859b4d81efc5b76de14d01fd900af7d7bd1be/uv-0.9.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1d4c624bb2b81f885b7182d99ebdd5c2842219d2ac355626a4a2b6c1e3e6f8c1", size = 19961915, upload-time = "2025-11-26T16:17:15.587Z" }, 996 - { url = "https://files.pythonhosted.org/packages/a3/d1/0f0c8dc2125709a8e072b73e5e89da9f016d492ca88b909b23b3006c2b51/uv-0.9.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:318d0b9a39fa26f95a428a551d44cbefdfd58178954a831669248a42f39d3c75", size = 18426731, upload-time = "2025-11-26T16:17:31.855Z" }, 997 - { url = "https://files.pythonhosted.org/packages/36/ee/f9db8cb69d584b8326b3e0e60e5a639469cdebac76e7f4ff5ba7c2c6fe6c/uv-0.9.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6a641ed7bcc8d317d22a7cb1ad0dfa41078c8e392f6f9248b11451abff0ccf50", size = 20315156, upload-time = "2025-11-26T16:17:08.125Z" }, 998 - { url = "https://files.pythonhosted.org/packages/8a/49/045bbfe264fc1add3e238e0e11dec62725c931946dbcda3780d15ca3591b/uv-0.9.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e797ae9d283ee129f33157d84742607547939ca243d7a8c17710dc857a7808bd", size = 20430487, upload-time = "2025-11-26T16:17:28.143Z" }, 999 - { url = "https://files.pythonhosted.org/packages/ff/c0/18a14dbaedfd2492de5cca50b46a238d5199e9f0291f027f63a03f2ebdd4/uv-0.9.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48fa9cf568c481c150f957a2f9285d1c3ad2c1d50c904b03bcebd5c9669c5668", size = 21378284, upload-time = "2025-11-26T16:16:48.696Z" }, 1000 - { url = "https://files.pythonhosted.org/packages/08/04/d0fc5fb25e3f90740913b1c028e1556515e4e1fea91e1f58e7c18c1712a3/uv-0.9.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a66817a416c1c79303fd5e40c319ed9c8e59b46fb04cf3eac4447e95b9ec8763", size = 23016232, upload-time = "2025-11-26T16:16:46.149Z" }, 1001 - { url = "https://files.pythonhosted.org/packages/e4/bc/cef461a47cddeb99c2a3b31f3946d38cbca7923b0f2fb6666756ba63a84a/uv-0.9.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05eb7e941c54666e8c52519a79ff46d15b5206967645652d3dfb2901fd982493", size = 22657140, upload-time = "2025-11-26T16:17:03.026Z" }, 1002 - { url = "https://files.pythonhosted.org/packages/39/0f/5c9de65279480b1922c51aae409bbfa1d90ff108f8b81688022499f2c3e2/uv-0.9.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fe5ac5b0a98a876da8f4c08e03217589a89ea96883cfdc9c4b397bf381ef7b9", size = 21644453, upload-time = "2025-11-26T16:16:43.228Z" }, 1003 - { url = "https://files.pythonhosted.org/packages/da/e5/148ab5edb339f5833d04f0bcb8380a53e8b19bd5f091ae67222ed188b393/uv-0.9.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6627d0abbaf58f9ff6e07e3f8522d65421230969aa2d7b10421339f0cb30dec4", size = 21655007, upload-time = "2025-11-26T16:16:51.36Z" }, 1004 - { url = "https://files.pythonhosted.org/packages/eb/d8/a77587e4608af6efc5a72d3a937573eb5d08052550a3f248821b50898626/uv-0.9.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cca7671efacf6e2950eb86273ecce4a9a3f8bfa6ac04e8a17be9499bb3bb882", size = 20448163, upload-time = "2025-11-26T16:16:53.768Z" }, 1005 - { url = "https://files.pythonhosted.org/packages/81/ad/e3bb28d175f22edf1779a81b76910e842dcde012859556b28e9f4b630f26/uv-0.9.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2e00a4f8404000e86074d7d2fe5734078126a65aefed1e9a39f10c390c4c15dc", size = 21477072, upload-time = "2025-11-26T16:16:56.908Z" }, 1006 - { url = "https://files.pythonhosted.org/packages/32/b6/9231365ab2495107a9e23aa36bb5400a4b697baaa0e5367f009072e88752/uv-0.9.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:3a4c16906e78f148c295a2e4f2414b843326a0f48ae68f7742149fd2d5dafbf7", size = 20421263, upload-time = "2025-11-26T16:17:10.552Z" }, 1007 - { url = "https://files.pythonhosted.org/packages/8c/83/d83eeee9cea21b9a9e053d4a2ec752a3b872e22116851317da04681cc27e/uv-0.9.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f254cb60576a3ae17f8824381f0554120b46e2d31a1c06fc61432c55d976892d", size = 20855418, upload-time = "2025-11-26T16:17:05.552Z" }, 1008 - { url = "https://files.pythonhosted.org/packages/6e/88/70102f374cfbbb284c6fe385e35978bff25a70b8e6afa871886af8963595/uv-0.9.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d50cea327b994786866b2d12c073097b9c8d883d42f0c0408b2d968492f571a4", size = 21871073, upload-time = "2025-11-26T16:17:00.213Z" }, 1009 - { url = "https://files.pythonhosted.org/packages/01/05/00c90367db0c81379c9d2b1fb458a09a0704ecd89821c071cb0d8a917752/uv-0.9.13-py3-none-win32.whl", hash = "sha256:a80296b1feb61bac36aee23ea79be33cd9aa545236d0780fbffaac113a17a090", size = 19607949, upload-time = "2025-11-26T16:17:23.337Z" }, 1010 - { url = "https://files.pythonhosted.org/packages/2f/e0/718b433acf811388e309936524be5786b8e0cc8ff23128f9cc29a34c075b/uv-0.9.13-py3-none-win_amd64.whl", hash = "sha256:5732cd0fe09365fa5ad2c0a2d0c007bb152a2aa3c48e79f570eec13fc235d59d", size = 21722341, upload-time = "2025-11-26T16:17:20.764Z" }, 1011 - { url = "https://files.pythonhosted.org/packages/9f/31/142457b7c9d5edcdd8d4853c740c397ec83e3688b69d0ef55da60f7ab5b5/uv-0.9.13-py3-none-win_arm64.whl", hash = "sha256:edfc3d53b6adefae766a67672e533d7282431f0deb2570186d1c3dd0d0e3c0a3", size = 20042030, upload-time = "2025-11-26T16:17:18.058Z" }, 1012 - ] 1013 - 1014 - [[package]] 1015 - name = "virtualenv" 1016 - version = "20.34.0" 1017 - source = { registry = "https://pypi.org/simple" } 1018 - dependencies = [ 1019 - { name = "distlib" }, 1020 - { name = "filelock" }, 1021 - { name = "platformdirs" }, 1022 - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, 1023 - ] 1024 - sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } 1025 - wheels = [ 1026 - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, 1027 - ] 1028 - 1029 - [[package]] 1030 - name = "wcwidth" 1031 - version = "0.2.13" 1032 - source = { registry = "https://pypi.org/simple" } 1033 - sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } 1034 - wheels = [ 1035 - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, 1036 - ]