-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpyproject.toml
More file actions
357 lines (338 loc) · 16.5 KB
/
Copy pathpyproject.toml
File metadata and controls
357 lines (338 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
[project]
# The PyPI distribution name. NOT the import name (that is `dacli`) and
# not the command (that is `da`). It is `da-sync` rather than `da-cli`
# because PyPI refuses `da-cli` as too similar to the unrelated `dacli`
# project — the two differ only by a separator, which is precisely what
# its typosquat guard exists to catch. Note the name being free is not
# sufficient: `da-cli` returns 404 and is still rejected.
name = "da-sync"
description = "Sync DeviantArt galleries to local folders — zero-dependency Python CLI with OAuth 2.1 PKCE, a SQLite index, and scheduled macOS syncs."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "FZ2000 and contributors" }]
keywords = [
"deviantart", "deviantart-api", "deviantart-downloader",
"gallery-downloader", "art-downloader", "downloader",
"cli", "command-line-tool", "backup", "archive", "sync",
"oauth", "oauth2", "pkce", "sqlite", "macos", "launchd",
"keychain", "zero-dependencies", "stdlib",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Internet",
"Topic :: Multimedia :: Graphics",
"Topic :: Utilities",
"Intended Audience :: End Users/Desktop",
"Operating System :: MacOS :: MacOS X",
"Operating System :: POSIX :: Linux",
"Topic :: System :: Archiving :: Backup",
"Typing :: Typed",
]
dynamic = ["version"]
# Runtime dependencies: NONE. The dacli package imports stdlib only. Dev tooling
# (ruff, mypy, pytest) is declared under [project.optional-dependencies].
dependencies = []
[project.optional-dependencies]
# Full dev toolchain — what CI and `make dev-setup` install.
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.6",
"mypy>=1.10",
]
# Minimal subsets, for callers who don't need the full dev meta-package.
test = [
"pytest>=8.0",
"pytest-cov>=5.0",
]
lint = [
"ruff>=0.6",
"mypy>=1.10",
]
# Network integration tests (Layer 3 cassettes). Adds VCR.py + pytest-vcr
# for record/replay against real DA API responses.
integration = [
"vcrpy>=4.4",
"pytest-vcr>=1.0.0",
]
[project.urls]
Homepage = "https://github.com/FZ2000/da-cli"
Repository = "https://github.com/FZ2000/da-cli"
Issues = "https://github.com/FZ2000/da-cli/issues"
Changelog = "https://github.com/FZ2000/da-cli/blob/main/CHANGELOG.md"
Documentation = "https://github.com/FZ2000/da-cli#readme"
# `pip install da-sync` exposes a `da` script on PATH. This is the
# pip-installable counterpart of the `da` shim in the repo root; the
# shim still exists for the git-clone-and-symlink install path
# (see install.sh) used in the README.
[project.scripts]
da = "dacli:main"
# ----------------------------------------------------------------------------
# Build system (PEP 517) — required for reproducible `pip install`.
# Without this table pip falls back to a legacy default that emits a
# deprecation warning on pip 25+.
# ----------------------------------------------------------------------------
[build-system]
# >=77: `license = "MIT"` as an SPDX string plus `license-files`
# is PEP 639, which setuptools implemented in 77.0.0. On 68-76 the
# build fails validation — it only appears to work because pip's
# isolated build environment installs the newest setuptools.
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
# ----------------------------------------------------------------------------
# ruff — strict lint + formatter. Replaces flake8/black/isort.
# ----------------------------------------------------------------------------
[tool.ruff]
target-version = "py310"
line-length = 100
extend-exclude = ["build", "dist", ".venv"]
# The `da` entry-point shim is Python with no .py suffix. Ruff matches on
# extension, not shebang, so without this it is silently unlinted — it had
# 3 real violations and a formatting diff while `ruff check .` passed.
extend-include = ["da"]
required-version = ">=0.6"
[tool.ruff.lint]
# Hard mode. Each rule family is documented at https://docs.astral.sh/ruff/rules
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import order)
"B", # flake8-bugbear (likely-bug heuristics)
"C4", # flake8-comprehensions
"UP", # pyupgrade (modernise legacy syntax)
"SIM", # flake8-simplify
"RUF", # ruff-specific
"S", # flake8-bandit (security)
"PL", # pylint subset
"RET", # flake8-return
"PTH", # flake8-use-pathlib
"TRY", # tryceratops (exception handling)
"ANN", # flake8-annotations (require type annotations)
"D", # pydocstyle (docstrings)
"N", # pep8-naming (PEP 8 class/function/exception conventions)
"FURB", # refurb (modernisation: hex/base64 helpers, bit_count, etc.)
"PERF", # perflint (avoidable list-cast / loop-try patterns)
"PT", # flake8-pytest-style (pytest idiom enforcement, tests-only)
"RSE", # flake8-raise (raise Foo() not Foo(,))
"PGH", # pygrep-hooks (noqa must specify code; no blanket type:ignore)
"FLY", # flynt (f-string instead of .format()/%)
"ERA", # eradicate (no commented-out code)
"FIX", # flake8-fixme (no FIXME/XXX/HACK without an issue link)
"TD", # flake8-todos (TODO format)
"SLF", # flake8-self (private-member access outside owning class)
]
ignore = [
"ANN401", # Allow `Any` in some intentional places (DA JSON is third-party-schema)
"D100", # Missing module-level docstring (we have only one module)
"D103", # Missing docstring in public function — argparse handlers are self-doc'd via `help=`
"D104", # Missing __init__.py docstring
"D107", # __init__ docstring — class docstring covers it
"D203", # 1 blank line before class docstring (conflicts with D211)
"D212", # Multi-line docstring summary should start at the first line (we use PEP 257 second-line style; D213 is the inverse)
"D213", # Multi-line docstring summary should start at the second line (kept off in favour of explicit per-docstring style)
"D205", # Blank line between summary and description — stylistic
"D400", # First line ends with period — sometimes a colon is more apt
"D401", # Imperative-mood summary — too pedantic
"D415", # First line ends with punct — D400 covers this
"S101", # `assert` — pytest uses these; production code does not (per-file-ignored in tests)
"S105", # Hardcoded "password" detector trips on URLs containing /token, /secret, etc.
"S108", # `/tmp` in tests (per-file-ignored in tests)
"S311", # Non-cryptographic random — jitter doesn't need crypto; PKCE uses os.urandom
"PLR0913", # Too many function arguments — fine for argparse-driven dispatchers
"PLR0912", # Too many branches — sync command bodies are inherently branchy
"PLR0915", # Too many statements — same
"PLR2004", # Magic value comparisons — over-zealous in HTTP code (200/404 etc.)
"PERF203", # try-except in loop — the http retry loops need it; can't be hoisted without losing retry state
"TRY003", # Long exception messages — useful for users
"TRY300", # `else` after try — sometimes harms readability
"TRY301", # raise within try — needed for retry loops
"TRY400", # `logging.exception` instead of `logging.error` — we don't use logging module
"S310", # urllib.urlopen — we validate URLs ourselves
"S603", # subprocess audit reminder — every subprocess.run in this codebase is hand-audited
"S607", # subprocess partial path (`security`, `launchctl`, `openssl`) — all in /usr/bin
"RUF012", # Mutable class attribs — false positives on argparse handler classes
"PTH123", # open(file) — sometimes more readable than Path(...).open()
"PTH101", # os.chmod — sometimes clearer next to os.open
"PTH111", # os.path.expanduser — fine for one-off dest resolution
"ANN202", # Private function return types — over-pedantic for small helpers
"SIM117", # Combined `with` statements — separate is clearer when contexts have different scopes
"PLC0415", # Import-outside-toplevel — ok in cmd_bench's lazy tempfile import
"TD002", # TODO without assignee — issue tracker link is enough
"TD003", # TODO without issue link — link the GitHub issue inline when warranted
"FIX002", # FIXME — same; surfaced via code review
]
[tool.ruff.lint.per-file-ignores]
# Submodules resolve patched names through the package (`dacli._keychain_get`,
# `dacli._STATE_CORRUPTION_WARNED`). That is deliberate — see ADR 0007 — and
# reads as private access only because the package is its own namespace.
"dacli/config.py" = ["SLF001"]
"dacli/index.py" = ["SLF001"]
"dacli/sync.py" = ["SLF001"]
"dacli/auth.py" = ["SLF001"]
# Command handlers reach the same patched names through the package.
"dacli/commands/*.py" = ["SLF001"]
# cmd_bench saves and restores the index connection where it now lives.
# F401 is deliberately NOT suppressed here. The package __init__ re-exports
# the names extracted into submodules, and each one says so with an explicit
# `X as X` alias — the form ruff recognises as a re-export. A blanket
# suppression hid 28 imports that had stopped being anything at all.
"dacli/__init__.py" = ["SLF001"]
# argparse exposes no public accessor for its subparser tree, so both
# doc tools walk `_actions` / `_SubParsersAction` to reach it.
"tools/check_doc_flags.py" = ["SLF001"]
"tools/gen_cli_docs.py" = ["SLF001"]
"tests/**" = [
"D", # docstrings optional in tests
"ANN", # annotations optional in tests
"S101", # `assert` is the point of tests
"S105", # hardcoded "secret" strings in tests are intentional fixtures
"S106", # ditto
"S108", # `/tmp` in tests is intentional
"PLR2004", # magic numbers in assertions are normal
"SLF001", # access to "private" _funcs is the point of unit tests
"PT018", # `assert A and B` is fine in tests when the conjunction is the assertion
"PT012", # `pytest.raises` block complexity is a readability call, not a rule
"N806", # test fixtures may use API-shaped mixedCase (e.g. `Usage` from DA)
"PERF401", # manual list-build in test setup is often clearer than a comprehension
]
"da" = [
"D", # 15-line shim, no module docstring needed beyond the existing one
"E402", # sys.path setup before import is intentional
]
"tests/test_integration.py" = [
"ERA001", # one intentional commented-out example for documentation
]
# Task tags recognised by TD / FIX rule families (top-level lint setting).
# Listed here to keep all lint config in one block.
# task-tags = ["TODO", "FIXME", "XXX", "HACK", "BUG"] # default already matches
[tool.ruff.lint.pydocstyle]
convention = "google" # enforces Args:/Returns:/Raises: section formatting
[tool.ruff.lint.isort]
known-first-party = ["dacli"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
# ----------------------------------------------------------------------------
# mypy — strict type checking
# ----------------------------------------------------------------------------
[tool.mypy]
# Pragmatic configuration: catches real bugs without drowning in `Any` errors
# from stdlib `json.loads()`, which legitimately returns `Any`. Strict-mode
# would force us to write a TypedDict for every DA API response — overkill
# for a single-file CLI that talks to a third-party schema we don't control.
python_version = "3.10"
# Type-check as macOS regardless of the CI host OS: the Keychain helpers are
# darwin-only behind sys.platform guards, and mypy on the Linux runner would
# otherwise narrow them the other way and flag their bodies as unreachable.
platform = "darwin"
files = ["dacli"]
warn_unreachable = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_untyped_decorators = true
# All five verified to pass with zero findings before being enabled, so they
# cost nothing today and stop a regression tomorrow. They are the strict flags
# that do not require annotating every local — the ones this codebase already
# satisfies without knowing it.
check_untyped_defs = true # type-check bodies of unannotated functions
no_implicit_optional = true # `def f(x: int = None)` is an error, not Optional
strict_equality = true # `x == y` where the types cannot overlap
disallow_untyped_calls = true # calling an unannotated function from a typed one
disallow_incomplete_defs = true # a signature annotated only in part
disallow_untyped_defs = true
no_implicit_reexport = true
# Relax the rules that produce noise from JSON-shaped values
disallow_any_generics = false
warn_return_any = false
disable_error_code = [
"type-arg", # bare `dict` is fine in argparse-driven helpers
"no-any-return", # json.loads() returns Any
"attr-defined", # `obj.get(...)` on json-derived `object` values
"operator", # arithmetic on json-derived `object` values
"arg-type", # str|object polymorphism in JSON responses
"index", # subscripting json-derived `object` values
"assignment", # str|None narrowing in mask_secret
]
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false
# ----------------------------------------------------------------------------
# pytest + coverage
# ----------------------------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
# Exclude network tests from the default coverage-gated run so they
# don't dilute the coverage percentage when they're skipped. Use
# `pytest -m integration` to run them explicitly.
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--cov=dacli",
"--cov-report=term-missing",
"--cov-report=xml:coverage.xml",
"--cov-fail-under=92", # lock current state; ratchet upward with each PR
]
markers = [
"integration: tests that hit the live DA API or replay cassettes (skipped by default; opt in with `-m integration`)",
"integration_anonymous: client_credentials grant; no user, no expiry; CI-safe",
"integration_authenticated: refresh_token from state.json or DA_REFRESH_TOKEN; 90-day cadence",
"integration_cassette: VCR.py cassette replay; CI-safe after first recording",
"vcr: marks tests for VCR.py cassette record/replay (pytest-vcr plugin)",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning:http.cookiejar", # stdlib noise
# Python 3.14 raises ResourceWarning when an HTTPError carrying a tempfile fp
# is garbage-collected. urllib.error.HTTPError is constructed with `fp=None`
# in our test fixtures, but pytest's unraisable hook still trips on the
# default-finalised HTTPError state. Silence both.
"ignore::ResourceWarning",
"ignore::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
branch = true
source = ["dacli"]
omit = ["tests/*", "tests/integration/*"]
[tool.coverage.report]
show_missing = true
skip_covered = false
exclude_also = [
"if __name__ == .__main__.:",
"raise NotImplementedError",
"pragma: no cover",
"if sys.platform != .darwin.:", # platform-specific branches we can't exec everywhere
]
# ----------------------------------------------------------------------------
# setuptools — package discovery
# ----------------------------------------------------------------------------
# Discover dacli and every subpackage. This must stay a glob: an explicit
# list silently omits new subpackages, which produces a wheel that imports
# at the top level and then fails on the first subcommand. The `da` shim at
# the repo root serves the git-clone install path; the entry point above
# serves the pip path.
[tool.setuptools.packages.find]
include = ["dacli*"]
namespaces = false
# PEP 561 marker. Without this in the wheel, a downstream project's mypy
# ignores our annotations entirely and the `Typing :: Typed` classifier is
# a claim we do not honour. The package is mypy --strict clean, so the
# marker is accurate.
[tool.setuptools.package-data]
dacli = ["py.typed"]
# Single source of truth for the version: __version__ in dacli/__init__.py.
[tool.setuptools.dynamic]
version = { attr = "dacli.__version__" }