mpql 0.0.1
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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/exe/mpql +7 -0
- data/lib/mpql/cli.rb +255 -0
- data/lib/mpql/client.rb +124 -0
- data/lib/mpql/configuration.rb +66 -0
- data/lib/mpql/date_parser.rb +37 -0
- data/lib/mpql/formatters/json_formatter.rb +22 -0
- data/lib/mpql/formatters/row_builder.rb +146 -0
- data/lib/mpql/formatters/table_formatter.rb +36 -0
- data/lib/mpql/formatters/tsv_formatter.rb +36 -0
- data/lib/mpql/version.rb +5 -0
- data/lib/mpql.rb +29 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: efb2efbefe64a0440d1fc21620820791b9c8dbf6298b6cc55b22cc70d54f4a55
|
|
4
|
+
data.tar.gz: a4dc264a5b02f0b7d6ee6e07f754bfebd4ef18b205cbb27a199f6238d49dcdb9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bbeb54de0e804fffe71383d78edd38d44e28070e8f1b0b89ae309ca927bf76ab9e564819a0e498e19d4ce73f9813567172488c89a738d0b361a1143a921189d6
|
|
7
|
+
data.tar.gz: 2441597897c95693cfe5b7a2fb41ffe628cf72afb3c9ec1d195af03b1dc7fb62a1650009f9d689a3da29cf14900c3cf49d840d900f6b81c160b405dc0e8961a6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tomorrowkey
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# mpql
|
|
2
|
+
|
|
3
|
+
A command-line tool to execute MixPanel Query API requests. Designed for both human users and AI agents.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
gem install mpql
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Create `~/.mpql.yml`:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
username: your-service-account-username
|
|
17
|
+
secret: your-service-account-secret
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or use environment variables:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
MIXPANEL_USERNAME=your-service-account-username
|
|
24
|
+
MIXPANEL_SECRET=your-service-account-secret
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Segmentation
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
mpql segmentation --project-id 123456 --event "Signed Up" --from 7d --to today
|
|
33
|
+
mpql segmentation --project-id 123456 --event "Page View" --from 2026-03-01 --to 2026-03-09 --on "properties.$browser" --unit day
|
|
34
|
+
mpql segmentation --project-id 123456 --event "Purchase" --from 1m --to today --type unique --format tsv
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Funnels
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
mpql funnels --project-id 123456 --funnel-id 12345 --from 7d --to today
|
|
41
|
+
mpql funnels --project-id 123456 --funnel-id 12345 --from 2026-03-01 --to 2026-03-09 --on "properties.$os"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Retention
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
mpql retention --project-id 123456 --from 30d --to today
|
|
48
|
+
mpql retention --project-id 123456 --from 2026-02-01 --to 2026-03-09 --born-event "Signed Up" --event "Login"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Insights
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
mpql insights --project-id 123456 --bookmark-id 67890
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Cohorts
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
mpql cohorts --project-id 123456
|
|
61
|
+
mpql cohorts --project-id 123456 --format table
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Export (Raw Event Data)
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
mpql export --project-id 123456 --event "ScreenView" --from 7d --to today
|
|
68
|
+
mpql export --project-id 123456 --event "Signed Up" --from 2026-03-01 --to 2026-03-09
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Engage (User Profiles)
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
mpql engage --project-id 123456
|
|
75
|
+
mpql engage --project-id 123456 --where 'properties["$email"] == "test@example.com"'
|
|
76
|
+
mpql engage --project-id 123456 --distinct-id "user123"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Date Formats
|
|
80
|
+
|
|
81
|
+
- `yyyy-mm-dd` (e.g., `2026-03-01`)
|
|
82
|
+
- `today`, `yesterday`
|
|
83
|
+
- `Nd` - N days ago (e.g., `7d`)
|
|
84
|
+
- `Nw` - N weeks ago (e.g., `2w`)
|
|
85
|
+
- `Nm` - N months ago (e.g., `1m`)
|
|
86
|
+
|
|
87
|
+
### Output Formats
|
|
88
|
+
|
|
89
|
+
- `--format json` (default) - JSON output
|
|
90
|
+
- `--format tsv` - Tab-separated values
|
|
91
|
+
- `--format table` - ASCII table
|
|
92
|
+
- `--raw` - Raw JSON without pretty-printing
|
|
93
|
+
|
|
94
|
+
### Global Options
|
|
95
|
+
|
|
96
|
+
- `--project-id` - MixPanel project ID (required)
|
|
97
|
+
- `--region` - MixPanel region (`us`, `eu`, `in`). Defaults to `us`
|
|
98
|
+
|
|
99
|
+
## Release
|
|
100
|
+
|
|
101
|
+
1. Update the version number in `lib/mpql/version.rb`
|
|
102
|
+
2. Commit the changes
|
|
103
|
+
3. Run `rake release`
|
|
104
|
+
|
|
105
|
+
This will create a git tag, build the gem, and push it to [rubygems.org](https://rubygems.org).
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
data/exe/mpql
ADDED
data/lib/mpql/cli.rb
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "formatters/json_formatter"
|
|
6
|
+
require_relative "formatters/tsv_formatter"
|
|
7
|
+
require_relative "formatters/table_formatter"
|
|
8
|
+
|
|
9
|
+
module Mpql
|
|
10
|
+
class CLI < Thor
|
|
11
|
+
# Exit with non-zero status on errors
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_option :format, type: :string, default: "json", enum: %w[json tsv table],
|
|
17
|
+
desc: "Output format (json, tsv, table)"
|
|
18
|
+
class_option :raw, type: :boolean, default: false,
|
|
19
|
+
desc: "Output raw JSON without pretty-printing"
|
|
20
|
+
class_option :region, type: :string, enum: %w[us eu in],
|
|
21
|
+
desc: "MixPanel region (us, eu, in)"
|
|
22
|
+
class_option :project_id, type: :string, required: true,
|
|
23
|
+
desc: "MixPanel project ID"
|
|
24
|
+
|
|
25
|
+
desc "segmentation", "Query segmentation data for an event"
|
|
26
|
+
long_desc <<~LONGDESC
|
|
27
|
+
Query segmentation data for a specific event, segmented by a property.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
|
|
31
|
+
mpql segmentation --project-id 123456 --event "Signed Up" --from 7d --to today
|
|
32
|
+
|
|
33
|
+
mpql segmentation --project-id 123456 --event "Page View" --from 2026-03-01 --to 2026-03-09 --on "properties.$browser" --unit day
|
|
34
|
+
|
|
35
|
+
mpql segmentation --project-id 123456 --event "Purchase" --from 1m --to today --type unique --format tsv
|
|
36
|
+
|
|
37
|
+
Date formats: yyyy-mm-dd, today, yesterday, Nd (days ago), Nw (weeks ago), Nm (months ago)
|
|
38
|
+
LONGDESC
|
|
39
|
+
option :event, type: :string, required: true, desc: "Event name"
|
|
40
|
+
option :from, type: :string, required: true, desc: "Start date"
|
|
41
|
+
option :to, type: :string, required: true, desc: "End date"
|
|
42
|
+
option :on, type: :string, desc: "Property expression to segment by"
|
|
43
|
+
option :unit, type: :string, enum: %w[minute hour day month], desc: "Time unit"
|
|
44
|
+
option :where, type: :string, desc: "Filter expression"
|
|
45
|
+
option :type, type: :string, enum: %w[general unique average], desc: "Analysis type"
|
|
46
|
+
option :limit, type: :numeric, desc: "Max number of property values to return"
|
|
47
|
+
def segmentation
|
|
48
|
+
result = client.segmentation(
|
|
49
|
+
event: options[:event],
|
|
50
|
+
from_date: parse_date(options[:from]),
|
|
51
|
+
to_date: parse_date(options[:to]),
|
|
52
|
+
**optional_params(:on, :unit, :where, :type, :limit)
|
|
53
|
+
)
|
|
54
|
+
output(result)
|
|
55
|
+
rescue Mpql::Error => e
|
|
56
|
+
error_exit(e)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc "funnels", "Query funnel analysis data"
|
|
60
|
+
long_desc <<~LONGDESC
|
|
61
|
+
Query data for a saved funnel.
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
|
|
65
|
+
mpql funnels --project-id 123456 --funnel-id 12345 --from 7d --to today
|
|
66
|
+
|
|
67
|
+
mpql funnels --project-id 123456 --funnel-id 12345 --from 2026-03-01 --to 2026-03-09 --on "properties.$os"
|
|
68
|
+
LONGDESC
|
|
69
|
+
option :funnel_id, type: :numeric, required: true, desc: "Funnel ID"
|
|
70
|
+
option :from, type: :string, required: true, desc: "Start date"
|
|
71
|
+
option :to, type: :string, required: true, desc: "End date"
|
|
72
|
+
option :on, type: :string, desc: "Property expression to segment by"
|
|
73
|
+
option :where, type: :string, desc: "Filter expression"
|
|
74
|
+
option :limit, type: :numeric, desc: "Max number of property values to return"
|
|
75
|
+
def funnels
|
|
76
|
+
result = client.funnels(
|
|
77
|
+
funnel_id: options[:funnel_id],
|
|
78
|
+
from_date: parse_date(options[:from]),
|
|
79
|
+
to_date: parse_date(options[:to]),
|
|
80
|
+
**optional_params(:on, :where, :limit)
|
|
81
|
+
)
|
|
82
|
+
output(result)
|
|
83
|
+
rescue Mpql::Error => e
|
|
84
|
+
error_exit(e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
desc "retention", "Query retention analysis data"
|
|
88
|
+
long_desc <<~LONGDESC
|
|
89
|
+
Query retention analysis data.
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
|
|
93
|
+
mpql retention --project-id 123456 --from 30d --to today
|
|
94
|
+
|
|
95
|
+
mpql retention --project-id 123456 --from 2026-02-01 --to 2026-03-09 --born-event "Signed Up" --event "Login"
|
|
96
|
+
LONGDESC
|
|
97
|
+
option :from, type: :string, required: true, desc: "Start date"
|
|
98
|
+
option :to, type: :string, required: true, desc: "End date"
|
|
99
|
+
option :born_event, type: :string, desc: "Birth event name"
|
|
100
|
+
option :event, type: :string, desc: "Return event name"
|
|
101
|
+
option :retention_type, type: :string, enum: %w[birth compounded], desc: "Retention type"
|
|
102
|
+
option :on, type: :string, desc: "Property expression to segment by"
|
|
103
|
+
option :unit, type: :string, enum: %w[day week month], desc: "Bucket unit"
|
|
104
|
+
option :interval, type: :numeric, desc: "Bucket interval"
|
|
105
|
+
def retention
|
|
106
|
+
result = client.retention(
|
|
107
|
+
from_date: parse_date(options[:from]),
|
|
108
|
+
to_date: parse_date(options[:to]),
|
|
109
|
+
**optional_params(:born_event, :event, :retention_type, :on, :unit, :interval)
|
|
110
|
+
)
|
|
111
|
+
output(result)
|
|
112
|
+
rescue Mpql::Error => e
|
|
113
|
+
error_exit(e)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "insights", "Query a saved Insights report"
|
|
117
|
+
long_desc <<~LONGDESC
|
|
118
|
+
Query data from a saved Insights report by its bookmark ID.
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
|
|
122
|
+
mpql insights --project-id 123456 --bookmark-id 67890
|
|
123
|
+
LONGDESC
|
|
124
|
+
option :bookmark_id, type: :numeric, required: true, desc: "Insights report bookmark ID"
|
|
125
|
+
def insights
|
|
126
|
+
result = client.insights(
|
|
127
|
+
bookmark_id: options[:bookmark_id],
|
|
128
|
+
**optional_params
|
|
129
|
+
)
|
|
130
|
+
output(result)
|
|
131
|
+
rescue Mpql::Error => e
|
|
132
|
+
error_exit(e)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc "cohorts", "List saved cohorts"
|
|
136
|
+
long_desc <<~LONGDESC
|
|
137
|
+
List all saved cohorts in the project.
|
|
138
|
+
|
|
139
|
+
Examples:
|
|
140
|
+
|
|
141
|
+
mpql cohorts --project-id 123456
|
|
142
|
+
|
|
143
|
+
mpql cohorts --project-id 123456 --format table
|
|
144
|
+
LONGDESC
|
|
145
|
+
def cohorts
|
|
146
|
+
result = client.cohorts(**optional_params)
|
|
147
|
+
output(result)
|
|
148
|
+
rescue Mpql::Error => e
|
|
149
|
+
error_exit(e)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
desc "engage", "Query user profiles"
|
|
153
|
+
long_desc <<~LONGDESC
|
|
154
|
+
Query user profiles using filter expressions.
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
|
|
158
|
+
mpql engage --project-id 123456
|
|
159
|
+
|
|
160
|
+
mpql engage --project-id 123456 --where 'properties["$email"] == "test@example.com"'
|
|
161
|
+
|
|
162
|
+
mpql engage --project-id 123456 --distinct-id "user123"
|
|
163
|
+
LONGDESC
|
|
164
|
+
option :where, type: :string, desc: "Filter expression"
|
|
165
|
+
option :distinct_id, type: :string, desc: "Specific user distinct ID"
|
|
166
|
+
option :page, type: :numeric, desc: "Result page number (starting from 0)"
|
|
167
|
+
option :session_id, type: :string, desc: "Session ID for pagination"
|
|
168
|
+
def engage
|
|
169
|
+
result = client.engage(**optional_params(:where, :distinct_id, :page, :session_id))
|
|
170
|
+
output(result)
|
|
171
|
+
rescue Mpql::Error => e
|
|
172
|
+
error_exit(e)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
desc "export", "Export raw event data"
|
|
176
|
+
long_desc <<~LONGDESC
|
|
177
|
+
Export raw event data for a specific event.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
|
|
181
|
+
mpql export --project-id 123456 --event "ScreenView" --from 7d --to today
|
|
182
|
+
|
|
183
|
+
mpql export --project-id 123456 --event "Signed Up" --from 2026-03-01 --to 2026-03-09
|
|
184
|
+
LONGDESC
|
|
185
|
+
option :event, type: :string, required: true, desc: "Event name"
|
|
186
|
+
option :from, type: :string, required: true, desc: "Start date"
|
|
187
|
+
option :to, type: :string, required: true, desc: "End date"
|
|
188
|
+
def export
|
|
189
|
+
result = client.export(
|
|
190
|
+
event: options[:event],
|
|
191
|
+
from_date: parse_date(options[:from]),
|
|
192
|
+
to_date: parse_date(options[:to])
|
|
193
|
+
)
|
|
194
|
+
output(result)
|
|
195
|
+
rescue Mpql::Error => e
|
|
196
|
+
error_exit(e)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
desc "version", "Show mpql version"
|
|
200
|
+
def version
|
|
201
|
+
puts Mpql::VERSION
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def client
|
|
207
|
+
apply_overrides
|
|
208
|
+
@client ||= Client.new
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def apply_overrides
|
|
212
|
+
config = Mpql.configuration
|
|
213
|
+
config.region = options[:region] if options[:region]
|
|
214
|
+
config.project_id = options[:project_id]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def parse_date(value)
|
|
218
|
+
DateParser.parse(value)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def output(data)
|
|
222
|
+
formatter = case options[:format]
|
|
223
|
+
when "tsv"
|
|
224
|
+
Formatters::TsvFormatter.new(data)
|
|
225
|
+
when "table"
|
|
226
|
+
Formatters::TableFormatter.new(data)
|
|
227
|
+
else
|
|
228
|
+
Formatters::JsonFormatter.new(data, raw: options[:raw])
|
|
229
|
+
end
|
|
230
|
+
$stdout.puts formatter.render
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def error_exit(error)
|
|
234
|
+
exit_code = case error
|
|
235
|
+
when AuthenticationError then 1
|
|
236
|
+
when ApiError then 2
|
|
237
|
+
when InputError then 3
|
|
238
|
+
when ConfigurationError then 4
|
|
239
|
+
else 1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
error_data = { error: error.class.name, message: error.message }
|
|
243
|
+
$stdout.puts JSON.generate(error_data)
|
|
244
|
+
exit exit_code
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def optional_params(*keys)
|
|
248
|
+
return {} if keys.empty?
|
|
249
|
+
|
|
250
|
+
keys.each_with_object({}) do |key, hash|
|
|
251
|
+
hash[key] = options[key] if options[key]
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/mpql/client.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Mpql
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(config = Mpql.configuration)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def segmentation(event:, from_date:, to_date:, **options)
|
|
14
|
+
params = { event: event, from_date: from_date, to_date: to_date }.merge(options)
|
|
15
|
+
get("/api/query/segmentation", params)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def funnels(funnel_id:, from_date:, to_date:, **options)
|
|
19
|
+
params = { funnel_id: funnel_id, from_date: from_date, to_date: to_date }.merge(options)
|
|
20
|
+
get("/api/query/funnels", params)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def retention(from_date:, to_date:, **options)
|
|
24
|
+
params = { from_date: from_date, to_date: to_date }.merge(options)
|
|
25
|
+
get("/api/query/retention", params)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def insights(bookmark_id:, **options)
|
|
29
|
+
params = { bookmark_id: bookmark_id }.merge(options)
|
|
30
|
+
get("/api/query/insights", params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cohorts(**options)
|
|
34
|
+
post("/api/query/cohorts/list", options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def engage(**options)
|
|
38
|
+
post("/api/query/engage", options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export(from_date:, to_date:, event:, **options)
|
|
42
|
+
params = { from_date: from_date, to_date: to_date, event: JSON.generate([event]) }.merge(options)
|
|
43
|
+
get_jsonl("/api/2.0/export", params)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def get(path, params)
|
|
49
|
+
@config.validate!
|
|
50
|
+
params[:project_id] = @config.project_id
|
|
51
|
+
uri = build_uri(path, params)
|
|
52
|
+
request = Net::HTTP::Get.new(uri)
|
|
53
|
+
execute(uri, request)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_jsonl(path, params)
|
|
57
|
+
@config.validate!
|
|
58
|
+
params[:project_id] = @config.project_id
|
|
59
|
+
uri = build_uri(path, params, base_url: @config.data_base_url)
|
|
60
|
+
request = Net::HTTP::Get.new(uri)
|
|
61
|
+
execute(uri, request, jsonl: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def post(path, params)
|
|
65
|
+
@config.validate!
|
|
66
|
+
params[:project_id] = @config.project_id
|
|
67
|
+
uri = build_uri(path)
|
|
68
|
+
request = Net::HTTP::Post.new(uri)
|
|
69
|
+
request.set_form_data(params)
|
|
70
|
+
execute(uri, request)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def execute(uri, request, jsonl: false)
|
|
74
|
+
request.basic_auth(@config.username, @config.secret)
|
|
75
|
+
|
|
76
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
77
|
+
http.request(request)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
handle_response(response, jsonl: jsonl)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_response(response, jsonl: false)
|
|
84
|
+
case response.code.to_i
|
|
85
|
+
when 200
|
|
86
|
+
if jsonl
|
|
87
|
+
parse_jsonl(response.body)
|
|
88
|
+
else
|
|
89
|
+
JSON.parse(response.body)
|
|
90
|
+
end
|
|
91
|
+
when 401, 403
|
|
92
|
+
raise AuthenticationError, build_error_message(response)
|
|
93
|
+
else
|
|
94
|
+
raise ApiError, build_error_message(response)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_jsonl(body)
|
|
99
|
+
body.each_line.filter_map do |line|
|
|
100
|
+
line = line.strip
|
|
101
|
+
next if line.empty?
|
|
102
|
+
|
|
103
|
+
JSON.parse(line)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_error_message(response)
|
|
108
|
+
body = begin
|
|
109
|
+
JSON.parse(response.body)
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
{ "error" => response.body }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
message = body["error"] || body["message"] || response.body
|
|
115
|
+
"HTTP #{response.code}: #{message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_uri(path, params = {}, base_url: @config.base_url)
|
|
119
|
+
uri = URI("#{base_url}#{path}")
|
|
120
|
+
uri.query = URI.encode_www_form(params.compact) unless params.empty?
|
|
121
|
+
uri
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mpql
|
|
6
|
+
class Configuration
|
|
7
|
+
REGIONS = {
|
|
8
|
+
"us" => "https://mixpanel.com",
|
|
9
|
+
"eu" => "https://eu.mixpanel.com",
|
|
10
|
+
"in" => "https://in.mixpanel.com"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
DATA_REGIONS = {
|
|
14
|
+
"us" => "https://data.mixpanel.com",
|
|
15
|
+
"eu" => "https://data-eu.mixpanel.com",
|
|
16
|
+
"in" => "https://data-in.mixpanel.com"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_accessor :username, :secret, :project_id, :region
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@username = nil
|
|
23
|
+
@secret = nil
|
|
24
|
+
@project_id = nil
|
|
25
|
+
@region = "us"
|
|
26
|
+
|
|
27
|
+
load_config_file
|
|
28
|
+
load_env
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def base_url
|
|
32
|
+
REGIONS.fetch(@region, REGIONS["us"])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def data_base_url
|
|
36
|
+
DATA_REGIONS.fetch(@region, DATA_REGIONS["us"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate!
|
|
40
|
+
errors = []
|
|
41
|
+
errors << "MIXPANEL_USERNAME is not set" unless @username
|
|
42
|
+
errors << "MIXPANEL_SECRET is not set" unless @secret
|
|
43
|
+
errors << "project_id is not set" unless @project_id
|
|
44
|
+
|
|
45
|
+
raise ConfigurationError, errors.join(", ") unless errors.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def load_config_file
|
|
51
|
+
config_path = File.expand_path("~/.mpql.yml")
|
|
52
|
+
return unless File.exist?(config_path)
|
|
53
|
+
|
|
54
|
+
config = YAML.safe_load_file(config_path, permitted_classes: [Symbol])
|
|
55
|
+
return unless config.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
@username = config["username"] || config[:username]
|
|
58
|
+
@secret = config["secret"] || config[:secret]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_env
|
|
62
|
+
@username = ENV["MIXPANEL_USERNAME"] if ENV["MIXPANEL_USERNAME"]
|
|
63
|
+
@secret = ENV["MIXPANEL_SECRET"] if ENV["MIXPANEL_SECRET"]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Mpql
|
|
6
|
+
module DateParser
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Parse a date string into yyyy-mm-dd format.
|
|
10
|
+
#
|
|
11
|
+
# Supported formats:
|
|
12
|
+
# - "today" -> today's date
|
|
13
|
+
# - "yesterday" -> yesterday's date
|
|
14
|
+
# - "Nd" -> N days ago (e.g., "7d")
|
|
15
|
+
# - "Nw" -> N weeks ago (e.g., "3w")
|
|
16
|
+
# - "Nm" -> N months ago (e.g., "1m")
|
|
17
|
+
# - "yyyy-mm-dd" -> passed through as-is
|
|
18
|
+
def parse(input)
|
|
19
|
+
case input.to_s.strip.downcase
|
|
20
|
+
when "today"
|
|
21
|
+
Date.today.strftime("%Y-%m-%d")
|
|
22
|
+
when "yesterday"
|
|
23
|
+
(Date.today - 1).strftime("%Y-%m-%d")
|
|
24
|
+
when /\A(\d+)d\z/
|
|
25
|
+
(Date.today - ::Regexp.last_match(1).to_i).strftime("%Y-%m-%d")
|
|
26
|
+
when /\A(\d+)w\z/
|
|
27
|
+
(Date.today - (::Regexp.last_match(1).to_i * 7)).strftime("%Y-%m-%d")
|
|
28
|
+
when /\A(\d+)m\z/
|
|
29
|
+
(Date.today << ::Regexp.last_match(1).to_i).strftime("%Y-%m-%d")
|
|
30
|
+
when /\A\d{4}-\d{2}-\d{2}\z/
|
|
31
|
+
Date.parse(input.strip).strftime("%Y-%m-%d")
|
|
32
|
+
else
|
|
33
|
+
raise InputError, "Invalid date format: '#{input}'. Use yyyy-mm-dd, today, yesterday, Nd, Nw, or Nm"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mpql
|
|
6
|
+
module Formatters
|
|
7
|
+
class JsonFormatter
|
|
8
|
+
def initialize(data, raw: false)
|
|
9
|
+
@data = data
|
|
10
|
+
@raw = raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
if @raw
|
|
15
|
+
JSON.generate(@data)
|
|
16
|
+
else
|
|
17
|
+
JSON.pretty_generate(@data)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mpql
|
|
4
|
+
module Formatters
|
|
5
|
+
class RowBuilder
|
|
6
|
+
attr_reader :headers, :rows
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@headers, @rows = build_table(data)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def build_table(data)
|
|
15
|
+
case data
|
|
16
|
+
when Array
|
|
17
|
+
build_from_array(data)
|
|
18
|
+
when Hash
|
|
19
|
+
build_from_hash(data)
|
|
20
|
+
else
|
|
21
|
+
[nil, [[data]]]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_from_array(data)
|
|
26
|
+
return [nil, []] if data.empty?
|
|
27
|
+
|
|
28
|
+
if data.all?(Hash)
|
|
29
|
+
headers = data.flat_map(&:keys).uniq
|
|
30
|
+
rows = data.map { |item| headers.map { |h| item[h] } }
|
|
31
|
+
[headers, rows]
|
|
32
|
+
else
|
|
33
|
+
[nil, data.map { |item| [item] }]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_from_hash(data)
|
|
38
|
+
if segmentation_data?(data)
|
|
39
|
+
build_from_segmentation(data)
|
|
40
|
+
elsif engage_data?(data)
|
|
41
|
+
build_from_engage(data)
|
|
42
|
+
elsif funnel_data?(data)
|
|
43
|
+
build_from_funnel(data)
|
|
44
|
+
elsif retention_data?(data)
|
|
45
|
+
build_from_retention(data)
|
|
46
|
+
else
|
|
47
|
+
build_from_flat_hash(data)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def segmentation_data?(data)
|
|
52
|
+
data.key?("data") &&
|
|
53
|
+
data["data"].is_a?(Hash) &&
|
|
54
|
+
data["data"].key?("values") &&
|
|
55
|
+
data["data"].key?("series")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_from_segmentation(data)
|
|
59
|
+
series = data["data"]["series"]
|
|
60
|
+
values = data["data"]["values"]
|
|
61
|
+
headers = ["segment"] + series
|
|
62
|
+
rows = values.map do |segment, date_values|
|
|
63
|
+
[segment] + series.map { |date| date_values[date] }
|
|
64
|
+
end
|
|
65
|
+
[headers, rows]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def engage_data?(data)
|
|
69
|
+
data.key?("results") && data["results"].is_a?(Array)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_from_engage(data)
|
|
73
|
+
results = data["results"]
|
|
74
|
+
return [nil, []] if results.empty?
|
|
75
|
+
|
|
76
|
+
rows = results.map { |r| flatten_hash(r) }
|
|
77
|
+
headers = rows.flat_map(&:keys).uniq
|
|
78
|
+
table_rows = rows.map { |r| headers.map { |h| r[h] } }
|
|
79
|
+
[headers, table_rows]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def funnel_data?(data)
|
|
83
|
+
data.key?("data") &&
|
|
84
|
+
data["data"].is_a?(Hash) &&
|
|
85
|
+
data["data"].values.any? { |v| v.is_a?(Hash) && v.key?("steps") }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_from_funnel(data)
|
|
89
|
+
headers = %w[date step_idx event count step_conv_ratio overall_conv_ratio]
|
|
90
|
+
rows = []
|
|
91
|
+
data["data"].each do |date, date_data|
|
|
92
|
+
next unless date_data.is_a?(Hash) && date_data["steps"].is_a?(Array)
|
|
93
|
+
|
|
94
|
+
date_data["steps"].each_with_index do |step, idx|
|
|
95
|
+
rows << [
|
|
96
|
+
date,
|
|
97
|
+
idx + 1,
|
|
98
|
+
step["event"],
|
|
99
|
+
step["count"],
|
|
100
|
+
step["step_conv_ratio"],
|
|
101
|
+
step["overall_conv_ratio"]
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
[headers, rows]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def retention_data?(data)
|
|
109
|
+
data.key?("data") &&
|
|
110
|
+
data["data"].is_a?(Hash) &&
|
|
111
|
+
!data["data"].key?("series")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_from_retention(data)
|
|
115
|
+
build_from_flat_hash(data)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_from_flat_hash(data)
|
|
119
|
+
flat = flatten_hash(data)
|
|
120
|
+
headers = %w[key value]
|
|
121
|
+
rows = flat.map { |k, v| [k, v] }
|
|
122
|
+
[headers, rows]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def flatten_hash(hash, prefix = nil)
|
|
126
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
127
|
+
full_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
128
|
+
case value
|
|
129
|
+
when Hash
|
|
130
|
+
result.merge!(flatten_hash(value, full_key))
|
|
131
|
+
when Array
|
|
132
|
+
if value.all?(Hash)
|
|
133
|
+
value.each_with_index do |item, i|
|
|
134
|
+
result.merge!(flatten_hash(item, "#{full_key}.#{i}"))
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
result[full_key] = value
|
|
138
|
+
end
|
|
139
|
+
else
|
|
140
|
+
result[full_key] = value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "terminal-table"
|
|
5
|
+
require_relative "row_builder"
|
|
6
|
+
|
|
7
|
+
module Mpql
|
|
8
|
+
module Formatters
|
|
9
|
+
class TableFormatter
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@builder = RowBuilder.new(data)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
table = Terminal::Table.new(
|
|
16
|
+
headings: @builder.headers,
|
|
17
|
+
rows: @builder.rows.map { |row| row.map { |v| format_value(v) } }
|
|
18
|
+
)
|
|
19
|
+
table.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def format_value(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil
|
|
27
|
+
""
|
|
28
|
+
when Hash, Array
|
|
29
|
+
JSON.generate(value)
|
|
30
|
+
else
|
|
31
|
+
value.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "row_builder"
|
|
5
|
+
|
|
6
|
+
module Mpql
|
|
7
|
+
module Formatters
|
|
8
|
+
class TsvFormatter
|
|
9
|
+
def initialize(data)
|
|
10
|
+
@builder = RowBuilder.new(data)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
lines = []
|
|
15
|
+
lines << @builder.headers.join("\t") if @builder.headers
|
|
16
|
+
@builder.rows.each { |row| lines << row.map { |v| format_value(v) }.join("\t") }
|
|
17
|
+
lines.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def format_value(value)
|
|
23
|
+
case value
|
|
24
|
+
when nil
|
|
25
|
+
""
|
|
26
|
+
when String
|
|
27
|
+
value.gsub("\t", "\\t").gsub("\n", "\\n")
|
|
28
|
+
when Hash, Array
|
|
29
|
+
JSON.generate(value).gsub("\t", "\\t").gsub("\n", "\\n")
|
|
30
|
+
else
|
|
31
|
+
value.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/mpql/version.rb
ADDED
data/lib/mpql.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mpql/version"
|
|
4
|
+
|
|
5
|
+
module Mpql
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class AuthenticationError < Error; end
|
|
8
|
+
class ApiError < Error; end
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
class InputError < Error; end
|
|
11
|
+
|
|
12
|
+
autoload :Configuration, "mpql/configuration"
|
|
13
|
+
autoload :Client, "mpql/client"
|
|
14
|
+
autoload :DateParser, "mpql/date_parser"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_configuration!
|
|
26
|
+
@configuration = nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mpql
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- tomorrowkey
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: terminal-table
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: thor
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.3'
|
|
40
|
+
description: A command-line tool to execute MixPanel Query API requests. Designed
|
|
41
|
+
for both human users and AI agents.
|
|
42
|
+
executables:
|
|
43
|
+
- mpql
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE.txt
|
|
48
|
+
- README.md
|
|
49
|
+
- exe/mpql
|
|
50
|
+
- lib/mpql.rb
|
|
51
|
+
- lib/mpql/cli.rb
|
|
52
|
+
- lib/mpql/client.rb
|
|
53
|
+
- lib/mpql/configuration.rb
|
|
54
|
+
- lib/mpql/date_parser.rb
|
|
55
|
+
- lib/mpql/formatters/json_formatter.rb
|
|
56
|
+
- lib/mpql/formatters/row_builder.rb
|
|
57
|
+
- lib/mpql/formatters/table_formatter.rb
|
|
58
|
+
- lib/mpql/formatters/tsv_formatter.rb
|
|
59
|
+
- lib/mpql/version.rb
|
|
60
|
+
homepage: https://github.com/tomorrowkey/mpql
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
rubygems_mfa_required: 'true'
|
|
65
|
+
source_code_uri: https://github.com/tomorrowkey/mpql
|
|
66
|
+
changelog_uri: https://github.com/tomorrowkey/mpql/blob/main/CHANGELOG.md
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 3.0.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 4.0.3
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: CLI tool for MixPanel Query API
|
|
84
|
+
test_files: []
|