circle-so-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e .[dev]
21
+ - run: pytest tests/ -v
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
20
+ with:
21
+ attestations: false
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ *.db
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damilola Afolabi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: circle-so-cli
3
+ Version: 0.1.0
4
+ Summary: CLI toolkit for managing Circle.so communities at scale
5
+ Project-URL: Homepage, https://github.com/boiyelove/circle-so
6
+ Project-URL: Repository, https://github.com/boiyelove/circle-so
7
+ Author: Damilola Afolabi
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: circle,circle.so,cli,community,management
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: circle-so-python-sdk>=0.1.0
18
+ Requires-Dist: click>=8.0
19
+ Requires-Dist: python-dotenv>=1.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # circle-so
26
+
27
+ CLI toolkit for managing Circle.so communities at scale.
28
+
29
+ Built on top of [circle-so-python-sdk](https://github.com/boiyelove/circle-so-python-sdk).
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install circle-so
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```bash
40
+ export CIRCLE_API_TOKEN="your_token"
41
+
42
+ # Spaces
43
+ circle-so spaces list --prefix kcna
44
+ circle-so spaces search "KCNA 048"
45
+ circle-so spaces lock --prefix kcna
46
+ circle-so spaces rename 1761784 --name "KCNA 072" --slug "kcna-072"
47
+
48
+ # Members
49
+ circle-so members import learners.csv
50
+ circle-so members audit --prefix kcna --cache
51
+ circle-so members add learners.csv --space "KCNA 048"
52
+ circle-so members fix-missing --dry-run
53
+ circle-so members move --from "KCNA 046" --to "KCNA 073" --max 100
54
+
55
+ # Moderators
56
+ circle-so moderators verify moderators.csv
57
+ circle-so moderators add moderators.csv
58
+
59
+ # Reports
60
+ circle-so report counts --prefix kcna
61
+ circle-so report inactive
62
+ circle-so report missing
63
+ circle-so report export moves
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ Set via environment variables or `.env` file:
69
+
70
+ ```bash
71
+ CIRCLE_API_TOKEN=your_token
72
+ CIRCLE_COMMUNITY_URL=https://your-community.circle.so
73
+ CIRCLE_SO_DB=./circle-so.db
74
+ CIRCLE_SO_DATA_DIR=~/Documents/Andela-K8s
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,55 @@
1
+ # circle-so
2
+
3
+ CLI toolkit for managing Circle.so communities at scale.
4
+
5
+ Built on top of [circle-so-python-sdk](https://github.com/boiyelove/circle-so-python-sdk).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install circle-so
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ export CIRCLE_API_TOKEN="your_token"
17
+
18
+ # Spaces
19
+ circle-so spaces list --prefix kcna
20
+ circle-so spaces search "KCNA 048"
21
+ circle-so spaces lock --prefix kcna
22
+ circle-so spaces rename 1761784 --name "KCNA 072" --slug "kcna-072"
23
+
24
+ # Members
25
+ circle-so members import learners.csv
26
+ circle-so members audit --prefix kcna --cache
27
+ circle-so members add learners.csv --space "KCNA 048"
28
+ circle-so members fix-missing --dry-run
29
+ circle-so members move --from "KCNA 046" --to "KCNA 073" --max 100
30
+
31
+ # Moderators
32
+ circle-so moderators verify moderators.csv
33
+ circle-so moderators add moderators.csv
34
+
35
+ # Reports
36
+ circle-so report counts --prefix kcna
37
+ circle-so report inactive
38
+ circle-so report missing
39
+ circle-so report export moves
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ Set via environment variables or `.env` file:
45
+
46
+ ```bash
47
+ CIRCLE_API_TOKEN=your_token
48
+ CIRCLE_COMMUNITY_URL=https://your-community.circle.so
49
+ CIRCLE_SO_DB=./circle-so.db
50
+ CIRCLE_SO_DATA_DIR=~/Documents/Andela-K8s
51
+ ```
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,58 @@
1
+ # Roadmap
2
+
3
+ ## Phase 1: Core CLI (Current)
4
+ - [x] Project scaffolding
5
+ - [ ] CLI framework with click
6
+ - [ ] Config management (env vars, .env, flags)
7
+ - [ ] DB schema with versioned migrations
8
+ - [ ] `spaces` commands: list, search, rename, lock/unlock
9
+ - [ ] `members` commands: import, audit, add, fix-missing
10
+ - [ ] `moderators` commands: verify, add
11
+ - [ ] `report` commands: counts, inactive, missing, export
12
+
13
+ ## Phase 2: Caching Layer
14
+ - [ ] `--cache` flag on read commands
15
+ - [ ] TTL-based cache in SQLite (default 1 hour)
16
+ - [ ] `--refresh` flag to force fresh fetch
17
+ - [ ] Cache invalidation on write operations
18
+ - [ ] Cache stats command (`circle-so cache stats`, `circle-so cache clear`)
19
+
20
+ ## Phase 3: Member Movement
21
+ - [ ] `members move` with engagement detection (posts, comments)
22
+ - [ ] Move planning with local preview (zero API calls)
23
+ - [ ] Move execution with verification
24
+ - [ ] Rollback support for failed moves
25
+ - [ ] Move history and audit trail in DB
26
+
27
+ ## Phase 4: Bulk Operations
28
+ - [ ] Batch member invitations with progress bar
29
+ - [ ] Batch space creation from CSV/template
30
+ - [ ] Batch moderator assignment (when Circle API supports per-space moderators)
31
+ - [ ] Rate limit awareness with ETA display
32
+
33
+ ## Phase 5: Observability
34
+ - [ ] `--verbose` and `--debug` flags on all commands
35
+ - [ ] Structured JSON logging option
36
+ - [ ] Operation audit log in DB (who did what, when)
37
+ - [ ] Diff reports (before/after state comparison)
38
+
39
+ ## Phase 6: Multi-Community
40
+ - [ ] Named profiles (`circle-so --profile staging`)
41
+ - [ ] Profile management (`circle-so profile add/list/switch`)
42
+ - [ ] Per-profile DB and cache isolation
43
+
44
+ ## Phase 7: Advanced Reports
45
+ - [ ] Member engagement scoring per space
46
+ - [ ] Inactive member detection with configurable thresholds
47
+ - [ ] Space health dashboard (member count, activity, moderator coverage)
48
+ - [ ] CSV/JSON/Markdown export formats
49
+
50
+ ## Phase 8: Plugin System
51
+ - [ ] Hook system for pre/post command execution
52
+ - [ ] Custom command registration
53
+ - [ ] Webhook receiver for real-time sync
54
+
55
+ ## Non-Goals
56
+ - Replacing the Circle.so web UI for day-to-day operations
57
+ - Real-time chat or notification management
58
+ - Content creation or moderation workflows
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "circle-so-cli"
7
+ version = "0.1.0"
8
+ description = "CLI toolkit for managing Circle.so communities at scale"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Damilola Afolabi" }]
13
+ keywords = ["circle", "circle.so", "community", "cli", "management"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "circle-so-python-sdk>=0.1.0",
23
+ "click>=8.0",
24
+ "python-dotenv>=1.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=7.0",
30
+ "ruff>=0.1.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ circle-so = "circle_so.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/boiyelove/circle-so"
38
+ Repository = "https://github.com/boiyelove/circle-so"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/circle_so"]
42
+
43
+ [tool.ruff]
44
+ target-version = "py39"
45
+ line-length = 120
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
@@ -0,0 +1,2 @@
1
+ """circle-so: CLI toolkit for managing Circle.so communities at scale."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,51 @@
1
+ """Main CLI entry point."""
2
+ import click
3
+ from circle_so.config import Config
4
+
5
+ pass_config = click.make_pass_decorator(Config, ensure=True)
6
+
7
+
8
+ @click.group()
9
+ @click.option("--db", envvar="CIRCLE_SO_DB", default="./circle-so.db", help="Path to SQLite database")
10
+ @click.option("--rate-limit", envvar="CIRCLE_SO_RATE_LIMIT", default=5, type=int, help="API requests per second")
11
+ @click.pass_context
12
+ def main(ctx, db, rate_limit):
13
+ """CLI toolkit for managing Circle.so communities at scale."""
14
+ ctx.ensure_object(Config)
15
+ ctx.obj = Config(db_path=db, rate_limit=rate_limit)
16
+
17
+
18
+ @main.group()
19
+ @pass_config
20
+ def spaces(config):
21
+ """Manage spaces."""
22
+
23
+
24
+ @main.group()
25
+ @pass_config
26
+ def members(config):
27
+ """Manage members."""
28
+
29
+
30
+ @main.group()
31
+ @pass_config
32
+ def moderators(config):
33
+ """Manage moderators."""
34
+
35
+
36
+ @main.group()
37
+ @pass_config
38
+ def report(config):
39
+ """Generate reports."""
40
+
41
+
42
+ # Register subcommands
43
+ from circle_so.commands.spaces import register as register_spaces
44
+ from circle_so.commands.members import register as register_members
45
+ from circle_so.commands.moderators import register as register_moderators
46
+ from circle_so.commands.report import register as register_report
47
+
48
+ register_spaces(spaces)
49
+ register_members(members)
50
+ register_moderators(moderators)
51
+ register_report(report)
@@ -0,0 +1 @@
1
+ """Commands package."""
@@ -0,0 +1,155 @@
1
+ """Member management commands."""
2
+ import csv
3
+ import click
4
+ from circle_so.config import Config
5
+
6
+ pass_config = click.make_pass_decorator(Config)
7
+
8
+
9
+ def register(group):
10
+ group.add_command(import_members)
11
+ group.add_command(audit)
12
+ group.add_command(add)
13
+ group.add_command(fix_missing)
14
+
15
+
16
+ @click.command("import")
17
+ @click.argument("csv_path", type=click.Path(exists=True))
18
+ @pass_config
19
+ def import_members(config, csv_path):
20
+ """Import members from CSV into local database."""
21
+ conn = config.get_db()
22
+ count = 0
23
+ with open(csv_path, newline="") as f:
24
+ reader = csv.DictReader(f)
25
+ plg_col = next((c for c in reader.fieldnames if c.lower() in ("plgs", "plg")), None)
26
+ country_col = next((c for c in reader.fieldnames if c.lower() == "country"), None)
27
+ for row in reader:
28
+ conn.execute(
29
+ "INSERT OR IGNORE INTO members (first_name, email, country, plg) VALUES (?, ?, ?, ?)",
30
+ (row.get("first_name", "").strip(), row.get("email", "").strip(),
31
+ row.get(country_col, "").strip() if country_col else "",
32
+ row.get(plg_col, "").strip() if plg_col else ""))
33
+ count += 1
34
+ conn.commit()
35
+ total = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
36
+ click.echo(f"Imported {count} rows. Total: {total}")
37
+
38
+
39
+ @click.command()
40
+ @click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
41
+ @click.option("--cache", is_flag=True, help="Use cached data")
42
+ @pass_config
43
+ def audit(config, prefix, cache):
44
+ """Audit members against Circle API."""
45
+ from circle.pagination import paginate
46
+ conn = config.get_db()
47
+ client = config.get_client()
48
+ where = f"AND plg LIKE '{prefix.upper()}%'" if prefix != "all" else ""
49
+ spaces = conn.execute(f"SELECT plg, space_id FROM spaces WHERE space_id IS NOT NULL {where} ORDER BY plg").fetchall()
50
+ click.echo(f"Auditing {len(spaces)} spaces...")
51
+ conn.execute(f"UPDATE members SET in_space = 0 WHERE 1=1 {where}")
52
+ for plg, space_id in spaces:
53
+ emails = set()
54
+ for m in paginate(client.admin.spaces.list_space_members, space_id=space_id, per_page=200):
55
+ cm = m.community_member
56
+ if cm and cm.email:
57
+ emails.add(cm.email.lower())
58
+ expected = conn.execute("SELECT id, email FROM members WHERE plg = ?", (plg,)).fetchall()
59
+ matched = sum(1 for lid, email in expected if email.strip().lower() in emails)
60
+ for lid, email in expected:
61
+ if email.strip().lower() in emails:
62
+ conn.execute("UPDATE members SET in_space = 1 WHERE id = ?", (lid,))
63
+ click.echo(f"{plg}: {matched}/{len(expected)} matched, {len(expected)-matched} missing")
64
+ conn.commit()
65
+ client.close()
66
+
67
+
68
+ @click.command()
69
+ @click.argument("csv_path", type=click.Path(exists=True))
70
+ @click.option("--space", required=True, help="PLG name, e.g. 'KCNA 048'")
71
+ @click.option("--dry-run", is_flag=True)
72
+ @pass_config
73
+ def add(config, csv_path, space, dry_run):
74
+ """Add members from CSV to a space."""
75
+ from circle.exceptions import CircleAPIError, NotFoundError
76
+ conn = config.get_db()
77
+ row = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (space,)).fetchone()
78
+ if not row:
79
+ click.echo(f"PLG '{space}' not found. Run: circle-so spaces list")
80
+ return
81
+ space_id = row[0]
82
+ users = []
83
+ with open(csv_path, newline="") as f:
84
+ for r in csv.reader(f):
85
+ if len(r) >= 2 and r[1].strip():
86
+ users.append({"name": r[0].strip(), "email": r[1].strip()})
87
+ click.echo(f"{len(users)} users -> {space} (id={space_id})")
88
+ if dry_run:
89
+ for u in users:
90
+ click.echo(f"[DRY RUN] {u['name']} ({u['email']})")
91
+ return
92
+ client = config.get_client()
93
+ sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
94
+ import os
95
+ success = failed = 0
96
+ for u in users:
97
+ try:
98
+ client.admin.spaces.add_space_member(email=u["email"], space_id=space_id)
99
+ success += 1
100
+ except NotFoundError:
101
+ try:
102
+ client.admin.community.create_community_member(
103
+ email=u["email"], name=u["name"], skip_invitation=True,
104
+ space_group_ids=[sg_id] if sg_id else [])
105
+ client.admin.spaces.add_space_member(email=u["email"], space_id=space_id)
106
+ success += 1
107
+ except CircleAPIError as e:
108
+ click.echo(f"FAILED: {u['email']}: {e}")
109
+ failed += 1
110
+ except CircleAPIError:
111
+ success += 1 # likely already member
112
+ click.echo(f"Done: {success} added, {failed} failed")
113
+ client.close()
114
+
115
+
116
+ @click.command("fix-missing")
117
+ @click.option("--dry-run", is_flag=True)
118
+ @pass_config
119
+ def fix_missing(config, dry_run):
120
+ """Add all members marked as missing in the database to their spaces."""
121
+ import os
122
+ from circle.exceptions import CircleAPIError, NotFoundError
123
+ conn = config.get_db()
124
+ missing = conn.execute("""
125
+ SELECT id, first_name, email, plg, space_id FROM members
126
+ WHERE in_space = 0 AND space_id IS NOT NULL ORDER BY plg
127
+ """).fetchall()
128
+ click.echo(f"{len(missing)} missing members")
129
+ if dry_run:
130
+ for _, name, email, plg, _ in missing:
131
+ click.echo(f"[DRY RUN] {name} ({email}) -> {plg}")
132
+ return
133
+ client = config.get_client()
134
+ sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
135
+ added = failed = 0
136
+ for lid, name, email, plg, space_id in missing:
137
+ try:
138
+ client.admin.spaces.add_space_member(email=email, space_id=space_id)
139
+ except NotFoundError:
140
+ try:
141
+ client.admin.community.create_community_member(
142
+ email=email, name=name, skip_invitation=True,
143
+ space_group_ids=[sg_id] if sg_id else [])
144
+ client.admin.spaces.add_space_member(email=email, space_id=space_id)
145
+ except CircleAPIError as e:
146
+ click.echo(f"FAILED: {email}: {e}")
147
+ failed += 1
148
+ continue
149
+ except CircleAPIError:
150
+ pass
151
+ conn.execute("UPDATE members SET in_space = 1 WHERE id = ?", (lid,))
152
+ added += 1
153
+ conn.commit()
154
+ click.echo(f"Done: {added} added, {failed} failed")
155
+ client.close()
@@ -0,0 +1,114 @@
1
+ """Moderator management commands."""
2
+ import csv
3
+ import click
4
+ from circle_so.config import Config
5
+
6
+ pass_config = click.make_pass_decorator(Config)
7
+
8
+
9
+ def register(group):
10
+ group.add_command(verify)
11
+ group.add_command(add)
12
+
13
+
14
+ @click.command()
15
+ @click.argument("csv_path", type=click.Path(exists=True))
16
+ @pass_config
17
+ def verify(config, csv_path):
18
+ """Verify moderators are in their spaces with moderator status."""
19
+ from circle.exceptions import CircleAPIError
20
+ conn = config.get_db()
21
+ client = config.get_client()
22
+ mods = _parse_moderators_csv(csv_path)
23
+ click.echo(f"{'Name':<35} {'PLG':<12} {'In Space':<10} {'Moderator'}")
24
+ click.echo("-" * 72)
25
+ in_space = not_in = moderators = 0
26
+ for m in mods:
27
+ space = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (m["plg"],)).fetchone()
28
+ if not space:
29
+ click.echo(f"{m['name']:<35} {m['plg']:<12} {'NO SPACE':<10} -")
30
+ continue
31
+ try:
32
+ sm = client.admin.spaces.show_space_member(email=m["email"], space_id=space[0])
33
+ is_mod = sm.moderator
34
+ click.echo(f"{m['name']:<35} {m['plg']:<12} {'YES':<10} {'YES' if is_mod else 'NO'}")
35
+ in_space += 1
36
+ if is_mod:
37
+ moderators += 1
38
+ except CircleAPIError:
39
+ click.echo(f"{m['name']:<35} {m['plg']:<12} {'NO':<10} -")
40
+ not_in += 1
41
+ click.echo(f"\n{in_space} in space, {not_in} missing, {moderators}/{in_space} moderators")
42
+ client.close()
43
+
44
+
45
+ @click.command()
46
+ @click.argument("csv_path", type=click.Path(exists=True))
47
+ @click.option("--dry-run", is_flag=True)
48
+ @pass_config
49
+ def add(config, csv_path, dry_run):
50
+ """Add moderators to their spaces (membership only -- moderator role requires Circle UI)."""
51
+ import os
52
+ from circle.exceptions import CircleAPIError, NotFoundError
53
+ conn = config.get_db()
54
+ client = config.get_client()
55
+ sg_id = int(os.environ.get("CIRCLE_SPACE_GROUP_ID", "0"))
56
+ mods = _parse_moderators_csv(csv_path)
57
+ click.echo(f"{len(mods)} moderators to process")
58
+ added = already = failed = 0
59
+ for m in mods:
60
+ space = conn.execute("SELECT space_id FROM spaces WHERE plg=?", (m["plg"],)).fetchone()
61
+ if not space:
62
+ click.echo(f"SKIP: {m['name']} -> {m['plg']} (no space)")
63
+ continue
64
+ if dry_run:
65
+ click.echo(f"[DRY RUN] {m['name']} -> {m['plg']}")
66
+ continue
67
+ try:
68
+ client.admin.spaces.show_space_member(email=m["email"], space_id=space[0])
69
+ already += 1
70
+ continue
71
+ except CircleAPIError:
72
+ pass
73
+ try:
74
+ client.admin.spaces.add_space_member(email=m["email"], space_id=space[0])
75
+ click.echo(f"Added: {m['name']} -> {m['plg']}")
76
+ added += 1
77
+ except NotFoundError:
78
+ try:
79
+ client.admin.community.create_community_member(
80
+ email=m["email"], name=m["name"], skip_invitation=True,
81
+ space_group_ids=[sg_id] if sg_id else [])
82
+ client.admin.spaces.add_space_member(email=m["email"], space_id=space[0])
83
+ click.echo(f"Invited+Added: {m['name']} -> {m['plg']}")
84
+ added += 1
85
+ except CircleAPIError as e:
86
+ click.echo(f"FAILED: {m['name']}: {e}")
87
+ failed += 1
88
+ except CircleAPIError:
89
+ already += 1
90
+ click.echo(f"\nDone: {added} added, {already} already, {failed} failed")
91
+ click.echo("NOTE: Moderator role must be assigned in Circle UI.")
92
+ client.close()
93
+
94
+
95
+ def _parse_moderators_csv(csv_path):
96
+ """Auto-detect CSV format for moderators."""
97
+ mods = []
98
+ with open(csv_path, newline="") as f:
99
+ reader = csv.reader(f)
100
+ first = next(reader)
101
+ f.seek(0)
102
+ if any("email" in c.lower() for c in first):
103
+ for row in csv.DictReader(f):
104
+ email = (row.get("email") or row.get("Email Address") or row.get("Email") or "").strip()
105
+ name = f"{row.get('first_name', row.get('First Name', '')).strip()} {row.get('last_name', row.get('Last Name', '')).strip()}".strip()
106
+ plg = (row.get("PLGs") or row.get("PLG") or "").strip()
107
+ if email:
108
+ mods.append({"email": email, "name": name, "plg": plg})
109
+ else:
110
+ f.seek(0)
111
+ for row in csv.reader(f):
112
+ if len(row) >= 3:
113
+ mods.append({"email": row[0].strip(), "name": f"{row[1].strip()} {row[2].strip()}", "plg": row[-1].strip()})
114
+ return mods
@@ -0,0 +1,94 @@
1
+ """Report commands."""
2
+ import csv
3
+ import os
4
+ import click
5
+ from circle_so.config import Config
6
+
7
+ pass_config = click.make_pass_decorator(Config)
8
+
9
+
10
+ def register(group):
11
+ group.add_command(counts)
12
+ group.add_command(inactive)
13
+ group.add_command(missing)
14
+ group.add_command(export)
15
+
16
+
17
+ @click.command()
18
+ @click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
19
+ @click.option("--over", default=0, type=int, help="Only show PLGs with more than N members")
20
+ @pass_config
21
+ def counts(config, prefix, over):
22
+ """Show member counts per PLG."""
23
+ conn = config.get_db()
24
+ where = f"WHERE m.plg LIKE '{prefix.upper()}%'" if prefix != "all" else ""
25
+ rows = conn.execute(f"""
26
+ SELECT m.plg, COUNT(*) as cnt, s.space_id, s.slug
27
+ FROM members m LEFT JOIN spaces s ON m.plg = s.plg
28
+ {where} GROUP BY m.plg ORDER BY m.plg
29
+ """).fetchall()
30
+ click.echo(f"{'PLG':<12} {'Count':<8} {'Space ID':<10} {'Slug'}")
31
+ click.echo("-" * 45)
32
+ total = 0
33
+ for plg, cnt, sid, slug in rows:
34
+ if cnt > over:
35
+ click.echo(f"{plg:<12} {cnt:<8} {sid or 'MISSING':<10} {slug or 'MISSING'}")
36
+ total += cnt
37
+ click.echo(f"\nTotal: {total}")
38
+
39
+
40
+ @click.command()
41
+ @pass_config
42
+ def inactive(config):
43
+ """List inactive members from last audit."""
44
+ conn = config.get_db()
45
+ try:
46
+ rows = conn.execute("""
47
+ SELECT space_name, COUNT(*) FROM move_audit WHERE status='inactive'
48
+ GROUP BY space_name ORDER BY space_name
49
+ """).fetchall()
50
+ for name, cnt in rows:
51
+ click.echo(f"{name}: {cnt} inactive")
52
+ except Exception:
53
+ click.echo("No audit data. Run: circle-so members audit")
54
+
55
+
56
+ @click.command()
57
+ @pass_config
58
+ def missing(config):
59
+ """List members not in their assigned space."""
60
+ conn = config.get_db()
61
+ rows = conn.execute("""
62
+ SELECT plg, COUNT(*) FROM members
63
+ WHERE in_space = 0 AND space_id IS NOT NULL
64
+ GROUP BY plg ORDER BY plg
65
+ """).fetchall()
66
+ if not rows:
67
+ click.echo("No missing members.")
68
+ return
69
+ for plg, cnt in rows:
70
+ click.echo(f"{plg}: {cnt} missing")
71
+
72
+
73
+ @click.command()
74
+ @click.argument("report_type", type=click.Choice(["inactive", "missing", "moves", "plg_changes"]))
75
+ @click.option("--output", "-o", default=None, help="Output path (default: data dir)")
76
+ @pass_config
77
+ def export(config, report_type, output):
78
+ """Export report to CSV."""
79
+ conn = config.get_db()
80
+ queries = {
81
+ "inactive": "SELECT space_name, email, access_type, created_at FROM move_audit WHERE status='inactive' ORDER BY space_name",
82
+ "missing": "SELECT plg, first_name, email FROM members WHERE in_space=0 AND space_id IS NOT NULL ORDER BY plg",
83
+ "moves": "SELECT from_plg, to_plg, COUNT(*) FROM moves WHERE status='done' GROUP BY from_plg, to_plg",
84
+ "plg_changes": "SELECT name, email, from_plg, to_plg FROM moves WHERE status='done' ORDER BY to_plg",
85
+ }
86
+ query = queries[report_type]
87
+ rows = conn.execute(query).fetchall()
88
+ cols = [d[0] for d in conn.execute(query).description]
89
+ out_path = output or os.path.join(config.data_dir, f"{report_type}.csv")
90
+ with open(out_path, "w", newline="") as f:
91
+ w = csv.writer(f)
92
+ w.writerow(cols)
93
+ w.writerows(rows)
94
+ click.echo(f"Exported {len(rows)} rows to {out_path}")
@@ -0,0 +1,91 @@
1
+ """Space management commands."""
2
+ import click
3
+ from circle_so.config import Config
4
+
5
+ pass_config = click.make_pass_decorator(Config)
6
+
7
+
8
+ def register(group):
9
+ group.add_command(list_spaces)
10
+ group.add_command(search)
11
+ group.add_command(rename)
12
+ group.add_command(lock)
13
+
14
+
15
+ @click.command("list")
16
+ @click.option("--prefix", default="all", help="Filter by prefix: kcna, ckad, or all")
17
+ @click.option("--cache", is_flag=True, help="Use cached data instead of API")
18
+ @pass_config
19
+ def list_spaces(config, prefix, cache):
20
+ """List all spaces."""
21
+ from circle.pagination import paginate
22
+ client = config.get_client()
23
+ for s in paginate(client.admin.spaces.list_spaces, per_page=200):
24
+ slug = s.slug or ""
25
+ if prefix == "all" or slug.startswith(prefix.lower()):
26
+ click.echo(f"{s.name:<16} id={s.id:<10} slug={slug}")
27
+ client.close()
28
+
29
+
30
+ @click.command()
31
+ @click.argument("names", nargs=-1, required=True)
32
+ @pass_config
33
+ def search(config, names):
34
+ """Search for spaces by name or slug."""
35
+ from circle.pagination import paginate
36
+ client = config.get_client()
37
+ all_spaces = list(paginate(client.admin.spaces.list_spaces, per_page=200))
38
+ for target in names:
39
+ target_slug = target.lower().replace(" ", "-")
40
+ click.echo(f"'{target}':")
41
+ found = False
42
+ for s in all_spaces:
43
+ if (s.name and s.name.strip() == target) or (s.slug and s.slug.startswith(target_slug)):
44
+ click.echo(f" name='{s.name}' id={s.id} slug={s.slug} type={s.space_type}")
45
+ found = True
46
+ if not found:
47
+ click.echo(" Not found")
48
+ client.close()
49
+
50
+
51
+ @click.command()
52
+ @click.argument("space_id", type=int)
53
+ @click.option("--name", required=True, help="New space name")
54
+ @click.option("--slug", default=None, help="New slug (auto-generated if omitted)")
55
+ @pass_config
56
+ def rename(config, space_id, name, slug):
57
+ """Rename a space."""
58
+ client = config.get_client()
59
+ kwargs = {"name": name}
60
+ if slug:
61
+ kwargs["slug"] = slug
62
+ result = client.admin.spaces.update_space(space_id, **kwargs)
63
+ click.echo(f"Renamed: {result.name} (slug={result.slug})")
64
+ client.close()
65
+
66
+
67
+ @click.command()
68
+ @click.option("--prefix", default="all", help="Filter: kcna, ckad, or all")
69
+ @click.option("--unlock", is_flag=True, help="Unlock instead of lock")
70
+ @click.option("--dry-run", is_flag=True, help="Preview without changes")
71
+ @pass_config
72
+ def lock(config, prefix, unlock, dry_run):
73
+ """Lock/unlock spaces: disable member posting and member-adding."""
74
+ from circle.pagination import paginate
75
+ client = config.get_client()
76
+ action = "Unlocking" if unlock else "Locking"
77
+ success = 0
78
+ for s in paginate(client.admin.spaces.list_spaces, per_page=200):
79
+ slug = s.slug or ""
80
+ if prefix != "all" and not slug.startswith(prefix.lower()):
81
+ continue
82
+ if dry_run:
83
+ click.echo(f"[DRY RUN] {action}: {s.name}")
84
+ continue
85
+ client.admin.spaces.update_space(s.id,
86
+ is_post_disabled=not unlock,
87
+ prevent_members_from_adding_others=not unlock)
88
+ click.echo(f"{action}: {s.name}")
89
+ success += 1
90
+ click.echo(f"\nDone: {success} spaces")
91
+ client.close()
@@ -0,0 +1,29 @@
1
+ """Configuration management. Reads from env vars, .env file, and CLI flags."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+
10
+ class Config:
11
+ def __init__(self, **overrides):
12
+ self.api_token = overrides.get("api_token") or os.environ.get("CIRCLE_API_TOKEN")
13
+ self.community_url = overrides.get("community_url") or os.environ.get("CIRCLE_COMMUNITY_URL")
14
+ self.db_path = overrides.get("db_path") or os.environ.get("CIRCLE_SO_DB", "./circle-so.db")
15
+ self.data_dir = overrides.get("data_dir") or os.environ.get("CIRCLE_SO_DATA_DIR", ".")
16
+ self.rate_limit = int(overrides.get("rate_limit") or os.environ.get("CIRCLE_SO_RATE_LIMIT", "5"))
17
+ self.cache_ttl = int(overrides.get("cache_ttl") or os.environ.get("CIRCLE_SO_CACHE_TTL", "3600"))
18
+
19
+ def get_client(self):
20
+ from circle import CircleClient
21
+ return CircleClient(
22
+ api_token=self.api_token,
23
+ community_url=self.community_url,
24
+ rate_limit=self.rate_limit,
25
+ )
26
+
27
+ def get_db(self):
28
+ from circle_so.db.connection import get_connection
29
+ return get_connection(self.db_path)
@@ -0,0 +1 @@
1
+ """DB package."""
@@ -0,0 +1,109 @@
1
+ """DB connection and schema migrations."""
2
+ import sqlite3
3
+
4
+ MIGRATIONS = [
5
+ # v1: core tables
6
+ """
7
+ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
8
+ INSERT OR IGNORE INTO schema_version VALUES (0);
9
+ """,
10
+ # v1: spaces
11
+ """
12
+ CREATE TABLE IF NOT EXISTS spaces (
13
+ id INTEGER PRIMARY KEY,
14
+ plg TEXT UNIQUE,
15
+ space_id INTEGER,
16
+ slug TEXT
17
+ );
18
+ """,
19
+ # v2: members
20
+ """
21
+ CREATE TABLE IF NOT EXISTS members (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ first_name TEXT,
24
+ email TEXT UNIQUE,
25
+ country TEXT,
26
+ plg TEXT,
27
+ space_id INTEGER,
28
+ in_community BOOLEAN,
29
+ in_space BOOLEAN,
30
+ community_member_id INTEGER
31
+ );
32
+ """,
33
+ # v3: moderators
34
+ """
35
+ CREATE TABLE IF NOT EXISTS moderators (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ email TEXT,
38
+ name TEXT,
39
+ plg TEXT,
40
+ space_id INTEGER,
41
+ in_space BOOLEAN,
42
+ is_moderator BOOLEAN,
43
+ UNIQUE(email, plg)
44
+ );
45
+ """,
46
+ # v4: moves
47
+ """
48
+ CREATE TABLE IF NOT EXISTS moves (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ email TEXT,
51
+ name TEXT,
52
+ community_member_id INTEGER,
53
+ from_space_id INTEGER,
54
+ from_plg TEXT,
55
+ to_space_id INTEGER,
56
+ to_plg TEXT,
57
+ status TEXT DEFAULT 'planned'
58
+ );
59
+ """,
60
+ # v5: cache
61
+ """
62
+ CREATE TABLE IF NOT EXISTS cache (
63
+ key TEXT PRIMARY KEY,
64
+ data TEXT,
65
+ fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
66
+ );
67
+ CREATE TABLE IF NOT EXISTS space_members_cache (
68
+ id INTEGER PRIMARY KEY,
69
+ space_id INTEGER,
70
+ email TEXT,
71
+ community_member_id INTEGER,
72
+ moderator BOOLEAN,
73
+ access_type TEXT,
74
+ status TEXT,
75
+ created_at TEXT,
76
+ UNIQUE(space_id, email)
77
+ );
78
+ """,
79
+ # v6: engaged users
80
+ """
81
+ CREATE TABLE IF NOT EXISTS engaged_users (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ space_id INTEGER,
84
+ community_member_id INTEGER,
85
+ engagement_type TEXT,
86
+ UNIQUE(space_id, community_member_id, engagement_type)
87
+ );
88
+ """,
89
+ ]
90
+
91
+
92
+ def get_connection(db_path: str) -> sqlite3.Connection:
93
+ conn = sqlite3.connect(db_path)
94
+ conn.execute("PRAGMA journal_mode=WAL")
95
+ _migrate(conn)
96
+ return conn
97
+
98
+
99
+ def _migrate(conn: sqlite3.Connection):
100
+ conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)")
101
+ conn.execute("INSERT OR IGNORE INTO schema_version VALUES (0)")
102
+ current = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
103
+
104
+ for i, sql in enumerate(MIGRATIONS):
105
+ if i <= current:
106
+ continue
107
+ conn.executescript(sql)
108
+ conn.execute("INSERT INTO schema_version VALUES (?)", (i,))
109
+ conn.commit()
@@ -0,0 +1,28 @@
1
+ """Test fixtures."""
2
+ import os
3
+ import pytest
4
+ import sqlite3
5
+ from click.testing import CliRunner
6
+ from circle_so.cli import main
7
+ from circle_so.config import Config
8
+ from circle_so.db.connection import get_connection
9
+
10
+
11
+ @pytest.fixture
12
+ def runner():
13
+ return CliRunner()
14
+
15
+
16
+ @pytest.fixture
17
+ def tmp_db(tmp_path):
18
+ db_path = str(tmp_path / "test.db")
19
+ conn = get_connection(db_path)
20
+ yield db_path, conn
21
+ conn.close()
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def env_setup(tmp_path, monkeypatch):
26
+ monkeypatch.setenv("CIRCLE_API_TOKEN", "test_token")
27
+ monkeypatch.setenv("CIRCLE_SO_DB", str(tmp_path / "test.db"))
28
+ monkeypatch.setenv("CIRCLE_SO_DATA_DIR", str(tmp_path))
@@ -0,0 +1,139 @@
1
+ """Tests for CLI commands."""
2
+ import os
3
+ import csv
4
+ from click.testing import CliRunner
5
+ from circle_so.cli import main
6
+ from circle_so.db.connection import get_connection
7
+
8
+
9
+ class TestCLI:
10
+ def test_help(self):
11
+ result = CliRunner().invoke(main, ["--help"])
12
+ assert result.exit_code == 0
13
+ assert "spaces" in result.output
14
+ assert "members" in result.output
15
+ assert "moderators" in result.output
16
+ assert "report" in result.output
17
+
18
+ def test_spaces_help(self):
19
+ result = CliRunner().invoke(main, ["spaces", "--help"])
20
+ assert result.exit_code == 0
21
+ assert "list" in result.output
22
+ assert "search" in result.output
23
+ assert "rename" in result.output
24
+ assert "lock" in result.output
25
+
26
+ def test_members_help(self):
27
+ result = CliRunner().invoke(main, ["members", "--help"])
28
+ assert result.exit_code == 0
29
+ assert "import" in result.output
30
+ assert "audit" in result.output
31
+ assert "add" in result.output
32
+ assert "fix-missing" in result.output
33
+
34
+ def test_moderators_help(self):
35
+ result = CliRunner().invoke(main, ["moderators", "--help"])
36
+ assert result.exit_code == 0
37
+ assert "verify" in result.output
38
+ assert "add" in result.output
39
+
40
+ def test_report_help(self):
41
+ result = CliRunner().invoke(main, ["report", "--help"])
42
+ assert result.exit_code == 0
43
+ assert "counts" in result.output
44
+ assert "inactive" in result.output
45
+ assert "missing" in result.output
46
+ assert "export" in result.output
47
+
48
+
49
+ class TestMembersImport:
50
+ def test_import_csv(self, tmp_path, monkeypatch):
51
+ db_path = str(tmp_path / "test.db")
52
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
53
+
54
+ csv_path = str(tmp_path / "learners.csv")
55
+ with open(csv_path, "w", newline="") as f:
56
+ w = csv.writer(f)
57
+ w.writerow(["first_name", "email", "country", "PLG"])
58
+ w.writerow(["Alice", "alice@example.com", "Kenya", "KCNA 001"])
59
+ w.writerow(["Bob", "bob@example.com", "Nigeria", "KCNA 002"])
60
+
61
+ result = CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
62
+ assert result.exit_code == 0
63
+ assert "Imported 2" in result.output
64
+
65
+ conn = get_connection(db_path)
66
+ count = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
67
+ assert count == 2
68
+ conn.close()
69
+
70
+ def test_import_idempotent(self, tmp_path, monkeypatch):
71
+ db_path = str(tmp_path / "test.db")
72
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
73
+
74
+ csv_path = str(tmp_path / "learners.csv")
75
+ with open(csv_path, "w", newline="") as f:
76
+ w = csv.writer(f)
77
+ w.writerow(["first_name", "email", "country", "PLG"])
78
+ w.writerow(["Alice", "alice@example.com", "Kenya", "KCNA 001"])
79
+
80
+ CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
81
+ CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
82
+
83
+ conn = get_connection(db_path)
84
+ count = conn.execute("SELECT COUNT(*) FROM members").fetchone()[0]
85
+ assert count == 1
86
+ conn.close()
87
+
88
+
89
+ class TestReportCounts:
90
+ def test_counts_empty(self, tmp_path, monkeypatch):
91
+ db_path = str(tmp_path / "test.db")
92
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
93
+ result = CliRunner().invoke(main, ["--db", db_path, "report", "counts"])
94
+ assert result.exit_code == 0
95
+ assert "Total: 0" in result.output
96
+
97
+ def test_counts_with_data(self, tmp_path, monkeypatch):
98
+ db_path = str(tmp_path / "test.db")
99
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
100
+
101
+ csv_path = str(tmp_path / "learners.csv")
102
+ with open(csv_path, "w", newline="") as f:
103
+ w = csv.writer(f)
104
+ w.writerow(["first_name", "email", "country", "PLG"])
105
+ w.writerow(["Alice", "a@example.com", "Kenya", "KCNA 001"])
106
+ w.writerow(["Bob", "b@example.com", "Kenya", "KCNA 001"])
107
+ w.writerow(["Carol", "c@example.com", "Kenya", "KCNA 002"])
108
+
109
+ CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
110
+ result = CliRunner().invoke(main, ["--db", db_path, "report", "counts"])
111
+ assert result.exit_code == 0
112
+ assert "KCNA 001" in result.output
113
+ assert "KCNA 002" in result.output
114
+ assert "Total: 3" in result.output
115
+
116
+ def test_counts_with_prefix_filter(self, tmp_path, monkeypatch):
117
+ db_path = str(tmp_path / "test.db")
118
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
119
+
120
+ csv_path = str(tmp_path / "learners.csv")
121
+ with open(csv_path, "w", newline="") as f:
122
+ w = csv.writer(f)
123
+ w.writerow(["first_name", "email", "country", "PLG"])
124
+ w.writerow(["Alice", "a@example.com", "Kenya", "KCNA 001"])
125
+ w.writerow(["Bob", "b@example.com", "Kenya", "CKAD 001"])
126
+
127
+ CliRunner().invoke(main, ["--db", db_path, "members", "import", csv_path])
128
+ result = CliRunner().invoke(main, ["--db", db_path, "report", "counts", "--prefix", "kcna"])
129
+ assert "KCNA 001" in result.output
130
+ assert "CKAD" not in result.output
131
+
132
+
133
+ class TestReportMissing:
134
+ def test_missing_none(self, tmp_path, monkeypatch):
135
+ db_path = str(tmp_path / "test.db")
136
+ monkeypatch.setenv("CIRCLE_SO_DB", db_path)
137
+ result = CliRunner().invoke(main, ["--db", db_path, "report", "missing"])
138
+ assert result.exit_code == 0
139
+ assert "No missing" in result.output
@@ -0,0 +1,27 @@
1
+ """Tests for config."""
2
+ import os
3
+ from circle_so.config import Config
4
+
5
+
6
+ class TestConfig:
7
+ def test_defaults(self, monkeypatch):
8
+ monkeypatch.setenv("CIRCLE_API_TOKEN", "tok123")
9
+ c = Config()
10
+ assert c.api_token == "tok123"
11
+ assert c.rate_limit == 5
12
+ assert c.cache_ttl == 3600
13
+
14
+ def test_overrides(self):
15
+ c = Config(api_token="override", rate_limit=20, db_path="/tmp/test.db")
16
+ assert c.api_token == "override"
17
+ assert c.rate_limit == 20
18
+ assert c.db_path == "/tmp/test.db"
19
+
20
+ def test_env_vars(self, monkeypatch):
21
+ monkeypatch.setenv("CIRCLE_API_TOKEN", "env_tok")
22
+ monkeypatch.setenv("CIRCLE_SO_RATE_LIMIT", "15")
23
+ monkeypatch.setenv("CIRCLE_SO_CACHE_TTL", "7200")
24
+ c = Config()
25
+ assert c.api_token == "env_tok"
26
+ assert c.rate_limit == 15
27
+ assert c.cache_ttl == 7200
@@ -0,0 +1,33 @@
1
+ """Tests for DB connection and migrations."""
2
+ import sqlite3
3
+ from circle_so.db.connection import get_connection, MIGRATIONS
4
+
5
+
6
+ class TestMigrations:
7
+ def test_creates_tables(self, tmp_path):
8
+ db_path = str(tmp_path / "test.db")
9
+ conn = get_connection(db_path)
10
+ tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
11
+ assert "spaces" in tables
12
+ assert "members" in tables
13
+ assert "moderators" in tables
14
+ assert "moves" in tables
15
+ assert "cache" in tables
16
+ assert "schema_version" in tables
17
+ conn.close()
18
+
19
+ def test_migration_is_idempotent(self, tmp_path):
20
+ db_path = str(tmp_path / "test.db")
21
+ conn1 = get_connection(db_path)
22
+ conn1.close()
23
+ conn2 = get_connection(db_path)
24
+ version = conn2.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
25
+ assert version == len(MIGRATIONS) - 1
26
+ conn2.close()
27
+
28
+ def test_schema_version_tracks(self, tmp_path):
29
+ db_path = str(tmp_path / "test.db")
30
+ conn = get_connection(db_path)
31
+ version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
32
+ assert version >= 0
33
+ conn.close()